Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-11 17:10:30 +05:00
parent 4383291669
commit fec7986d80
16 changed files with 1322 additions and 587 deletions

View file

@ -22,7 +22,7 @@ class AmountVisualTransformation(
private val symbol: String? = null,
private val currencyCode: String? = null,
private val decimalFormat: DecimalFormat = DecimalFormat(),
private val symbolColor: Color,
private val symbolColor: Color = Color.Unspecified,
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
@ -31,21 +31,27 @@ class AmountVisualTransformation(
decimals,
)
formattedAmount = formattedAmount.ifEmpty { decimalFormat.defaultFormat() }
val formattedText = if (formattedAmount.isNotEmpty() && symbol != null) {
val formattedText = if (formattedAmount.isNotEmpty()) {
buildAnnotatedString {
if (currencyCode != null) {
append(
formatFiatEditableAmount(
fiatAmount = formattedAmount,
fiatCurrencyCode = currencyCode,
fiatCurrencySymbol = symbol,
fiatCurrencySymbolColor = symbolColor,
),
)
} else {
append(formattedAmount)
append(CURRENCY_SPACE)
appendColored(symbol, symbolColor)
when {
currencyCode != null && symbol != null -> {
append(
formatFiatEditableAmount(
fiatAmount = formattedAmount,
fiatCurrencyCode = currencyCode,
fiatCurrencySymbol = symbol,
fiatCurrencySymbolColor = symbolColor,
),
)
}
symbol != null -> {
append(formattedAmount)
append(CURRENCY_SPACE)
appendColored(symbol, symbolColor)
}
else -> {
append(formattedAmount)
}
}
}
} else {

View file

@ -0,0 +1,84 @@
package com.tangem.core.ui.components.fields.visualtransformations
import androidx.compose.ui.text.AnnotatedString
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.CURRENCY_SPACE
import com.tangem.core.ui.utils.defaultFormat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Locale
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class AmountVisualTransformationTest {
// Fixed US-style symbols so grouping (',') / decimal ('.') separators are deterministic across machines.
private fun usFormat(): DecimalFormat = DecimalFormat().apply {
decimalFormatSymbols = DecimalFormatSymbols(Locale.US)
}
@Test
fun `GIVEN symbol and no currency code WHEN filter THEN amount is followed by the symbol`() {
// Arrange
val sut = AmountVisualTransformation(decimals = 2, symbol = "ETH", decimalFormat = usFormat())
// Act
val result = sut.filter(AnnotatedString("1234.5"))
// Assert — "1,234.5" + non-breaking space + "ETH"
assertThat(result.text.text).isEqualTo("1,234.5${CURRENCY_SPACE}ETH")
}
@Test
fun `GIVEN no symbol and no currency code WHEN filter THEN only the formatted amount is shown`() {
// Arrange
val sut = AmountVisualTransformation(decimals = 2, decimalFormat = usFormat())
// Act
val result = sut.filter(AnnotatedString("1234.5"))
// Assert
assertThat(result.text.text).isEqualTo("1,234.5")
}
@Test
fun `GIVEN empty input WHEN filter THEN default formatted value is shown with symbol`() {
// Arrange
val format = usFormat()
val sut = AmountVisualTransformation(decimals = 2, symbol = "ETH", decimalFormat = format)
// Act
val result = sut.filter(AnnotatedString(""))
// Assert — empty input collapses to the default format, then the symbol branch appends the symbol
assertThat(result.text.text).isEqualTo("${format.defaultFormat()}${CURRENCY_SPACE}ETH")
}
@Test
fun `GIVEN grouping separators WHEN transformedToOriginal THEN separators are subtracted from offset`() {
// Arrange
val sut = AmountVisualTransformation(decimals = 2, symbol = "ETH", decimalFormat = usFormat())
val result = sut.filter(AnnotatedString("1234"))
// transformed text is "1,234 ETH" (one grouping separator before offset 5)
// Act — caret at the end of the digits in transformed space ("1,234" -> index 5)
val original = result.offsetMapping.transformedToOriginal(5)
// Assert — minus the one grouping separator => 4 original digits
assertThat(original).isEqualTo(4)
}
@Test
fun `GIVEN grouping separators WHEN originalToTransformed THEN offset accounts for inserted separators`() {
// Arrange
val sut = AmountVisualTransformation(decimals = 2, symbol = "ETH", decimalFormat = usFormat())
val result = sut.filter(AnnotatedString("1234"))
// Act — original caret after all 4 digits
val transformed = result.offsetMapping.originalToTransformed(4)
// Assert — coerced to just before the currency symbol; 4 digits + 1 separator = 5
assertThat(transformed).isEqualTo(5)
}
}

View file

@ -1,314 +1,189 @@
# Swap Feature
Token-to-token exchange feature. Users select FROM and TO tokens, get quotes from providers (DEX/CEX), approve ERC-20 allowances if needed, and execute swaps.
Token-to-token exchange. Users pick FROM and TO tokens, get quotes from providers
(DEX/CEX), approve ERC-20 allowances if needed, and execute the swap.
## Module Structure
## Module map
```
features/swap/
api/ — Public contracts (SwapComponent, SwapFeatureToggles)
impl/ — UI, model, navigation, DI, token selection subfeature
domain/ — Business logic (SwapInteractor) + domain models
api/ — Domain interfaces (SwapRepository)
models/ — Domain model types (SwapPair, SwapProvider, SwapState, etc.)
fee/ — Fee calculation package (see Fee Architecture below)
data/ — Repository implementations, Retrofit APIs, Moshi DTOs
api/ — Public contracts (SwapComponent, SwapFeatureToggles)
impl/ — UI, SwapModel, navigation, DI, token-selection subfeature
domain/ SwapInteractor + domain models
api/ — Domain interfaces (SwapRepository)
models/ — SwapPair, SwapProvider, SwapState, …
fee/ — Fee calculation (see Fee Architecture)
data/ — Repository impls, Retrofit APIs, Moshi DTOs
```
**Package naming:** API = `com.tangem.features.swap`, Impl = `com.tangem.feature.swap` (singular `feature`, legacy inconsistency).
**Package quirk:** API = `com.tangem.features.swap`, Impl = `com.tangem.feature.swap`
(singular `feature` — legacy inconsistency, follow it).
**Build commands:**
**Build / test:**
```bash
./gradlew :features:swap:impl:compileDebugKotlin
./gradlew :features:swap:api:compileDebugKotlin
./gradlew :features:swap:domain:compileDebugKotlin
./gradlew :features:swap:domain:test
./gradlew :features:swap:impl:detekt
```
## Key Components
## Where to start reading
### SwapComponent (API)
Entry point. `Params` requires `userWalletId`, optional `cryptoCurrency`, `screenSource`, `currencyPosition` (`FROM`/`TO`/`ANY`), and `tangemPayInput`.
| Symbol | Role | Path |
|---|---|---|
| `SwapComponent` | API entry point; `Params(userWalletId, cryptoCurrency?, screenSource, currencyPosition, tangemPayInput)` | `api/.../features/swap/SwapComponent.kt` |
| `DefaultSwapComponent` | Decompose component; creates `SwapModel`, owns the child stack + slots | `impl/.../feature/swap/DefaultSwapComponent.kt` |
| `SwapModel` | Central coordinator (~2100 lines). State holder + fee-selector bridge | `impl/.../feature/swap/model/SwapModel.kt` |
| `SwapProcessDataState` | Live domain state for the session (tokens, pairs, providers, `swapDataModel`, amount) | `impl/.../feature/swap/model/SwapProcessDataState.kt` |
| `StateBuilder` | Pure builder: `SwapProcessDataState``SwapStateHolder` (Compose UI state) | `impl/.../feature/swap/ui/StateBuilder.kt` |
| `SwapRouter` | Wraps `AppRouter` + `StackNavigation<SwapRoute>`; custom `back()` per route | `impl/.../feature/swap/router/SwapRoute.kt` |
| `SwapInteractor` | Domain API; `loadSwapFee` / `applySwapFee` are the unified fee entry points | `domain/.../feature/swap/domain/SwapInteractor.kt` |
| `SwapInteractorImpl` | ~28 deps; `findBestQuote` dispatches per-provider via `supervisorScope + async` | `domain/.../feature/swap/domain/SwapInteractorImpl.kt` |
File: `features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt`
`SwapModel` state worth knowing: `dataStateStateFlow` (reactive domain data) and
`uiState: SwapStateHolder` (Compose state); the inner `FeeSelectorRepository` wires the
send-v2 fee selector to `SwapInteractor.loadSwapFee`/`applySwapFee`.
### DefaultSwapComponent (impl)
Decompose component. Creates `SwapModel` via `getOrCreateModel(params)`.
**Child navigation:**
- `childStack(SwapRoute)``SwapRoute.Main`, `SwapRoute.Success`, `SwapRoute.SelectToken(isFromDirection)`, rendered via `Children` with fade animation
- `SlotNavigation<Unit>` — approval bottom sheet (`GiveApprovalComponent`)
- `SlotNavigation<FeeSelectorConfig>` — fee selector block
**Injected factories:** `SwapFeeSelectorBlockComponent.Factory`, `GiveApprovalComponent.Factory`, `ChooseTokenComponent.Factory`.
File: `features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt`
### SwapModel (impl)
`@ModelScoped`, extends `Model()`. Central coordinator — ~2100 lines.
**Key state:**
- `dataStateStateFlow: MutableStateFlow<SwapProcessDataState>` — reactive domain data (from/to tokens, pairs, providers, amounts, fees)
- `uiState: SwapStateHolder by mutableStateOf()` — Compose UI state built by `StateBuilder`
- `feeSelectorRepository: FeeSelectorRepository` — inner class that implements `SwapFeeSelectorBlockComponent.ModelRepositoryExtended`; wires the fee selector UI component to `SwapInteractor.loadSwapFee` and `SwapInteractor.applySwapFee`
- `stackNavigation: StackNavigation<SwapRoute>` — stack navigation exposed from `SwapRouter`
- `approvalSlotNavigation: SlotNavigation<Unit>` — approval bottom sheet
File: `features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt`
**Initialization flow (init block):**
1. Subscribes to `chooseTokenBridge.onCurrencyChosen``onTokenSelect(result)`
2. Subscribes to `chooseTokenBridge.onClose` → pops slot navigation
3. Checks `ShouldShowStoriesUseCase` → pushes `AppRoute.Stories` if first-time swap
4. Resolves user country for FCA restrictions
5. Loads primary account status, initial currencies, and starts swap pair loading
**Token selection flow:**
1. User taps FROM or TO card → `onSelectTokenClick(direction)` pushes `SwapRoute.SelectToken(isFromDirection)` to stack
2. Stack creates `ChooseTokenComponent` with appropriate bridge (FROM or TO)
3. `ChooseTokenBridge` communicates selection result via Channel
4. `onTokenSelect(result)` assigns selected token to FROM or TO based on `isFromDirection`
**Swap execution flow:**
1. `onSwapClick()` — validates state, checks approval, initiates transaction
2. If approval needed → `approvalSlotNavigation.activate(Unit)`
3. On approval done → reloads quotes
4. On swap success → `swapRouter.openScreen(SwapRoute.Success)`
### SwapProcessDataState (impl)
Data class holding the live domain state for the current swap session.
Key fields: `fromSwapCurrencyStatus`, `toSwapCurrencyStatus`, `feePaidCryptoCurrency`, `pairs: List<SwapPairLeast>`, `selectedProvider`, `lastLoadedSwapStates: Map<SwapProvider, SwapState>`, `swapDataModel: SwapDataModel?`, `amount: String?`, `reduceBalanceBy`.
`getCurrentLoadedSwapState()` — convenience to get `lastLoadedSwapStates[selectedProvider] as? QuotesLoadedState`.
File: `features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt`
### StateBuilder (impl)
Pure transformation class. Takes `UiActions` + providers, builds `SwapStateHolder` from `SwapProcessDataState`.
Key methods: `createInitialLoadingState`, `createQuotesLoadedState`, `createSuccessState`, `loadingPermissionState`, `updateSwapAmount`, `addNotification`, `dismissBottomSheet`.
File: `features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt`
### SwapRouter (impl)
Wraps `AppRouter` + `StackNavigation<SwapRoute>`. `openScreen(SwapRoute)` pushes/replaces stack entries. `back()` has special logic: SelectToken pops local stack, Success exits to the screen before SwapCrypto in the app stack, Main pops AppRouter. `openTokenDetails()` navigates to `AppRoute.CurrencyDetails`.
File: `features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRoute.kt`
## Token Selection Subfeature (impl)
Self-contained within `choosetoken/` package:
- `ChooseTokenComponent` — API with `Params(bridge, settings, analyticsPayload)`
- `ChooseTokenBridge` — Channel-based communication: `onCurrencyChosen`, `onClose`, `onTokenSelected` (legacy), `onNewTokenAdded` (legacy). Has `settingsStateFlow` for dynamic settings.
- `ChooseTokenComponent.Settings``SwapFrom` (no market block) vs `SwapTo` (with market block)
- `ChooseTokenResult` — Contains `CryptoCurrencyStatus`, `AccountStatus`, `UserWallet`
- `DefaultChooseTokenComponent` — Has its own `ChooseTokenModel` and optional `AddToPortfolioComponent` bottom sheet slot
## Domain Layer
### SwapInteractor (interface)
File: `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt`
All public methods:
- `getPair(from, to, filterProviderTypes)``Either<ExpressError, List<SwapPairLeast>>`
- `findProvidersForPair(from, to, pairs)``List<SwapProvider>`
- `findProvidersForPairWithCheck(from, to, pairs)``List<SwapProvider>` (checks asset requirements/FCA)
- `findBestQuote(from, to, providers, amount, reduceBalanceBy)``Map<SwapProvider, SwapState>` (parallel per-provider)
- `onSwap(from, to, provider, swapData, amount, includeFeeInAmount, fee, operationType, isTangemPayWithdrawal)``SwapTransactionState`
- `loadSwapFee(provider, fromStatus, toStatus, amount, swapData, selectedFeeToken)``Either<GetFeeError, SwapFee>` — unified fee entry point (see Fee Architecture)
- `applySwapFee(state: QuotesLoadedState, fee: SwapFee)``QuotesLoadedState` — patches balance checks without re-fetching quotes
- `getTokenBalance(token)``SwapAmount`
- `getNativeToken(swapCurrencyStatus)``CryptoCurrency`
- `storeSwapTransaction(...)` — persists transaction for status tracking
### SwapInteractorImpl (impl)
File: `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt`
`@Inject` constructor with ~28 dependencies. Key injected components:
- `dexSwapFeeCalculator: DexSwapFeeCalculator` — fee calculation for DEX/DEX_BRIDGE
- `cexSwapFeeCalculator: CexSwapFeeCalculator` — fee calculation for CEX
`findBestQuote` dispatches per-provider using `supervisorScope + async`:
- `ExchangeProviderType.DEX` / `DEX_BRIDGE``manageDex(...)` or `manageDexSolana(...)`
- `ExchangeProviderType.CEX``manageCex(...)`
For DEX (non-Solana): if allowance OK and balance sufficient → `loadDexSwapDataNoFee(...)` which fetches exchange data but sets `feeState = NotEnough()` transiently. Fee is applied later via `applySwapFee`.
`onSwap` dispatch:
- CEX → `onSwapCex(...)` — fetches exchange data, then either `createAndSendGaslessTransactionUseCase` (token fee) or `sendTransactionUseCase` (native fee)
- DEX non-Solana → `onSwapDex(...)``createTransactionUseCase` with `createDexTxExtras(..., gasLimit = fee.fee.getGasLimit())`
- DEX Solana → compiled tx signed as-is; `fee` is only used for analytics/UI
### SwapTransferInteractor / SwapTransferInteractorImpl (domain)
Handles within-wallet transfers (same-wallet, same-account coin moves). `shouldTransferInsteadOfSwap(from, to)` detects same-wallet same-currency pairs. `updateTransfer(from, to, amount)` returns a `SwapState.Transfer` (not a quote). No fee calculation involved.
Files:
- `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt`
- `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt`
## Fee Architecture (post [REDACTED_TASK_KEY] refactor)
The fee subsystem was fully redesigned across three tickets ([REDACTED_TASK_KEY], [REDACTED_TASK_KEY], [REDACTED_TASK_KEY], [REDACTED_TASK_KEY]). All legacy `loadFeeForSwapTransaction`, `loadFeeForDex`, `getFeeForCex` overloads have been **removed**. The current design:
### Class Hierarchy
## Navigation
```
SwapInteractor.loadSwapFee() ← unified entry point (Phase 3)
├─ DEX/DEX_BRIDGE → DexSwapFeeCalculator.calculate() → DexFeeResult
│ ├─ Solana path: TransactionData.Compiled (no gas bump)
│ └─ EVM path: TransactionData.Uncompiled + patchEthGasLimitForSwap(DEX_PERCENTAGE=112)
│ └─ fallback: GetEthSpecificFeeUseCase on IllegalStateException
└─ CEX → CexSwapFeeCalculator.calculate() → CexFeeResult
├─ selectedFeeToken == null → EstimateFeeForGaslessTxUseCase (no gas bump)
├─ selectedFeeToken is Token → EstimateFeeForTokenUseCase (no gas bump)
└─ selectedFeeToken is Coin → EstimateFeeUseCase + patchEthGasLimitForSwap(SEND_PERCENTAGE=105)
SwapFeeFactory.from(transactionFeeResult, selectedFeeToken, otherNativeFee, feeBucket)
→ SwapFee (the single fee carrier used everywhere downstream)
SwapInteractor.applySwapFee(state, fee) ← patches QuotesLoadedState (Phase 4)
→ recomputes balanceStatus: SwapBalanceStatus (`Pending` / `Sufficient` / `FeeAdjustedAmount` / `InsufficientAmount` / `InsufficientFee`), currencyCheck, validationResult
AppRoute.Swap → DefaultSwapComponent
├─ childStack(SwapRoute)
│ ├─ Main → SwapScreen
│ ├─ Success → SwapSuccessScreen
│ └─ SelectToken → ChooseTokenComponent (FROM or TO bridge)
├─ SlotNavigation<Unit> → GiveApprovalComponent (bottom sheet)
└─ SlotNavigation<FeeSelectorConfig> → SwapFeeSelectorBlockComponent (inline)
```
### Key Types
Injected factories on `DefaultSwapComponent`: `SwapFeeSelectorBlockComponent.Factory`,
`GiveApprovalComponent.Factory`, `ChooseTokenComponent.Factory`.
| Type | File | Purpose |
|------|------|---------|
| `SwapFee` | `domain/models/ui/SwapFee.kt` | Unified carrier: `fee: Fee`, `transactionFeeResult: TransactionFeeResult`, `selectedFeeToken: CryptoCurrencyStatus`, `otherNativeFee: BigDecimal`, `feeBucket: FeeBucket` |
| `FeeBucket` | `domain/models/ui/FeeBucket.kt` | `SLOW/MARKET/FAST/SUGGESTED/CUSTOM`; `toAnalyticsName()` replaces legacy `FeeType.getNameForAnalytics()` |
| `TransactionFeeResult` | `domain/fee/TransactionFeeResult.kt` | Sealed: `Loaded(TransactionFee)` for native, `LoadedExtended(TransactionFeeExtended)` for gasless/token |
| `DexFeeResult` | `domain/fee/DexFeeResult.kt` | `transactionFee`, `otherNativeFee`, `gas: BigInteger?` |
| `CexFeeResult` | `domain/fee/CexFeeResult.kt` | `transactionFee: TransactionFeeResult` |
| `DexSwapFeeCalculator` | `domain/fee/DexSwapFeeCalculator.kt` | Solana vs EVM branching, 12% gas bump |
| `CexSwapFeeCalculator` | `domain/fee/CexSwapFeeCalculator.kt` | gasless/token/native branching, 5% gas bump |
| `SwapFeeFactory` | `domain/fee/SwapFeeFactory.kt` | `fromLoaded`, `fromLoadedExtended`, `from` (polymorphic) + `selectFee` for bucket picking |
| `PatchEthGasLimitForSwap` | `domain/fee/PatchEthGasLimitForSwap.kt` | Multiplies ETH gas limit. `DEX_PERCENTAGE=112`, `SEND_PERCENTAGE=105` |
Token selection: tapping FROM/TO pushes `SwapRoute.SelectToken(isFromDirection)`; the
`ChooseTokenComponent` returns its result over a `ChooseTokenBridge` Channel
(`onCurrencyChosen``onTokenSelect`). Swap execution: `onSwapClick()` → (approval slot if
needed) → on success `SwapRoute.Success`.
### DI for Fee Classes
## Token selection subfeature (`impl/choosetoken/`)
Two `PatchEthGasLimitForSwap` instances with `@Qualifier`:
- `@SwapDexGasLimit``DEX_PERCENTAGE=112` → injected into `DexSwapFeeCalculator`
- `@SwapSendGasLimit``SEND_PERCENTAGE=105` → injected into `CexSwapFeeCalculator`
Self-contained. `ChooseTokenComponent` (with `ChooseTokenModel`) communicates via
`ChooseTokenBridge` (Channel-based: `onCurrencyChosen`, `onClose`). `Settings` is `SwapFrom`
(no market block) vs `SwapTo` (with market block). Result type `ChooseTokenResult` carries
`CryptoCurrencyStatus`, `AccountStatus`, `UserWallet`.
Qualifiers: `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapFeeQualifiers.kt`
Bindings: `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt`
## Fee Architecture
### Fee Selector Wiring (SwapModel.FeeSelectorRepository)
`SwapModel` contains an inner class `FeeSelectorRepository` that implements `SwapFeeSelectorBlockComponent.ModelRepositoryExtended`. This is the bridge between the send-v2 fee selector UI component and the swap domain:
- `loadFeeExtended(selectedToken)` → calls `swapInteractor.loadSwapFee(...)`, wraps result as `TransactionFeeExtended` for the fee selector block
- `loadFee()` → same path, extracts `TransactionFee` from the `SwapFee` result
- `onResult(newState: FeeSelectorUM)` → when fee selector emits `Content`, calls `swapInteractor.applySwapFee(currentQuotesLoadedState, swapFee)` and updates `dataState.lastLoadedSwapStates`
DEX path requires a pre-fetched `swapDataModel` (populated by `loadDexSwapDataNoFee`). CEX passes `swapData = null`.
`FeeItem``FeeBucket` mapping lives at `SwapModel.FeeItem.toFeeBucket()` (line ~1921).
`getSelectedSwapFee()` (line ~1882) — reconstructs a `SwapFee` from `feeSelectorRepository.state.value as FeeSelectorUM.Content`.
### otherNativeFee (DEX bridge)
`ExpressTransactionModel.DEX.otherNativeFeeWei` — present only for `DEX_BRIDGE` providers. Converted from Wei in `DexSwapFeeCalculator.calculate()` and propagated as `DexFeeResult.otherNativeFee`. Carried through to `SwapFee.otherNativeFee`.
`applySwapFee` uses `fee.fee.amount.value + fee.otherNativeFee` as the balance check amount. `resolveOtherNativeFee()` in `SwapModel` reads it from `dataState.swapDataModel.transaction` since `FeeSelectorUM` does not carry it.
## Key Domain Models
- `SwapState` (sealed) — `QuotesLoadedState`, `Transfer`, `EmptyAmountState`, `SwapError`
- `QuotesLoadedState` carries `preparedSwapConfigState: PreparedSwapConfigState` (balance checks, fee state, includeFeeInAmount), `permissionState`, `swapDataModel`, `currencyCheck`, `validationResult`, `minAdaValue`, `swapProvider`
- `SwapProvider``providerId`, `name`, `type: ExchangeProviderType` (DEX/CEX/DEX_BRIDGE), rates, slippage, TOS links
- `SwapPairLeast` — from/to `LeastTokenInfo` (contractAddress + networkId) + `providers: List<SwapProvider>`
- `SwapDataModel` — quote result with `transaction: ExpressTransactionModel` (sealed: `DEX`, `CEX`)
- `SwapAmount``value: BigDecimal` + `decimals: Int`
- `TokenSwapInfo``tokenAmount: SwapAmount`, `amountFiat: BigDecimal`, `swapCurrencyStatus: SwapCurrencyStatus`
File locations:
- `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt`
- `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt`
- `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt`
## DI Modules
| Module | Scope | Purpose |
|--------|-------|---------|
| `SwapFeatureModule` | Singleton | `SwapComponent.Factory`, `SwapFeatureToggles` |
| `SwapModelModule` | ModelComponent | `SwapModel` into model map |
| `SwapEntryModule` | Singleton + Model | `SwapEntryComponent.Factory`, `SwapEntryModel` |
| `ChooseTokenModule` | Singleton + Model | `ChooseTokenComponent.Factory`, `ChooseTokenBridge.Factory`, `ChooseTokenModel` |
| `SwapSingletonModule` | Singleton | `AmountFormatter` |
| `SwapDomainModule` | Singleton | `DexSwapFeeCalculator`, `CexSwapFeeCalculator`, two `PatchEthGasLimitForSwap` instances with qualifiers |
| `SwapDomainBindModule` | Singleton | `SwapInteractor``SwapInteractorImpl`, `SwapTransferInteractor``SwapTransferInteractorImpl` |
## Analytics
`SwapEvents` sealed class hierarchy at `features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt`.
Fee tier analytics: `FeeBucket.toAnalyticsName()``"Min"/"Normal"/"Max"/"Suggested"/"Custom"`. Maps to `AnalyticsParam.FeeType.fromString(feeBucket.toAnalyticsName())`. The legacy `FeeType.getNameForAnalytics()` extension was removed in Phase 5 of the fee redesign.
## Navigation Summary
Single unified entry: `SwapInteractor.loadSwapFee()` → strategy calculator → `SwapFee`
carrier; `applySwapFee()` patches the loaded quote without re-fetching.
```
AppRouter (global)
└─ AppRoute.Swap → DefaultSwapComponent
├─ childStack(SwapRoute)
│ ├─ SwapRoute.Main → SwapMainChild (renders SwapScreen)
│ ├─ SwapRoute.Success → SwapSuccessChild (renders SwapSuccessScreen)
│ └─ SwapRoute.SelectToken → ChooseTokenComponent (FROM or TO bridge)
├─ SlotNavigation<Unit> (Approval)
│ └─ GiveApprovalComponent (bottom sheet)
└─ SlotNavigation<FeeSelectorConfig>
└─ SwapFeeSelectorBlockComponent (inline fee block)
loadSwapFee()
├─ DEX/DEX_BRIDGE → DexSwapFeeCalculator.calculate() → DexFeeResult
│ ├─ Solana: TransactionData.Compiled (NO gas bump)
│ └─ EVM: Uncompiled + patchEthGasLimitForSwap(DEX=112%)
│ └─ fallback GetEthSpecificFeeUseCase on IllegalStateException
└─ CEX → CexSwapFeeCalculator.calculate() → CexFeeResult
├─ feeToken == null → EstimateFeeForGaslessTxUseCase (no bump)
├─ feeToken Token → EstimateFeeForTokenUseCase (no bump)
└─ feeToken Coin → EstimateFeeUseCase + patchEthGasLimitForSwap(SEND=105%)
SwapFeeFactory.from(...) → SwapFee (the single fee carrier downstream)
applySwapFee(state, fee) → patches QuotesLoadedState.balanceStatus / currencyCheck / validationResult
```
## UI Layer
Fee types (all under `domain/fee/` unless noted) — open the file for fields:
`SwapFee` (`domain/models/ui/`, the carrier), `FeeBucket` (`domain/models/ui/`,
`SLOW/MARKET/FAST/SUGGESTED/CUSTOM` + `toAnalyticsName()`), `TransactionFeeResult` (sealed:
`Loaded` native / `LoadedExtended` gasless+token), `DexFeeResult`, `CexFeeResult`,
`DexSwapFeeCalculator`, `CexSwapFeeCalculator`, `SwapFeeFactory`, `PatchEthGasLimitForSwap`.
- `SwapScreen` — main swap composable (send card, receive card, swap button, provider, notifications, fee)
- `SwapSuccessScreen` — post-swap success with transaction details
- `SwapScreenContent` — layout with `ConstraintLayout` for card positioning
- `TransactionCard` / `TransactionCardEmpty` — token cards with amount input
- Token cards pass `TokenSelectionDirection.FROM` / `.TO` to `onSelectTokenClick`
**Fee selector wiring** (`SwapModel.FeeSelectorRepository`, implements
`SwapFeeSelectorBlockComponent.ModelRepositoryExtended`): `loadFeeExtended`/`loadFee` call
`loadSwapFee`; `onResult(FeeSelectorUM)` calls `applySwapFee` on `Content` and updates
`dataState.lastLoadedSwapStates`. `getSelectedSwapFee()` reconstructs a `SwapFee` from the
selector's current `Content` state. `FeeItem.toFeeBucket()` maps UI → bucket.
Files: `features/swap/impl/src/main/java/com/tangem/feature/swap/ui/`
**`otherNativeFee` (DEX bridge only):** `ExpressTransactionModel.DEX.otherNativeFeeWei`
(present only for `DEX_BRIDGE`) → converted in `DexSwapFeeCalculator``SwapFee.otherNativeFee`.
`applySwapFee` checks balance against `fee.amount.value + otherNativeFee`;
`resolveOtherNativeFee()` re-reads it from `dataState.swapDataModel.transaction`.
## Key domain models
`SwapState` (sealed, `domain/models/ui/SwapState.kt`): `QuotesLoadedState`, `Transfer`,
`EmptyAmountState`, `SwapError`. `QuotesLoadedState` carries `preparedSwapConfigState`
(balance/fee checks), `permissionState`, `swapDataModel`, `currencyCheck`, `validationResult`,
`swapProvider`. Other types — open the file: `SwapProvider` (has `type: ExchangeProviderType`
= DEX/CEX/DEX_BRIDGE), `SwapPairLeast`, `SwapDataModel` (`transaction: ExpressTransactionModel`
sealed DEX/CEX, `domain/models/domain/`), `SwapAmount`, `TokenSwapInfo`.
Transfers: `SwapTransferInteractor` handles same-wallet same-currency moves —
`shouldTransferInsteadOfSwap``SwapState.Transfer` (no quote, no fee).
## DI modules (where bindings live)
| Module | Provides |
|---|---|
| `SwapFeatureModule` | `SwapComponent.Factory`, `SwapFeatureToggles` |
| `SwapModelModule` / `SwapEntryModule` / `ChooseTokenModule` | Models into the model map + their factories |
| `SwapDomainModule` | `DexSwapFeeCalculator`, `CexSwapFeeCalculator`, the two qualified `PatchEthGasLimitForSwap` |
| `SwapDomainBindModule` | `SwapInteractor`/`SwapTransferInteractor` → impls |
Two `PatchEthGasLimitForSwap` instances are distinguished by `@SwapDexGasLimit` (112%) vs
`@SwapSendGasLimit` (105%) — qualifiers in `domain/di/SwapFeeQualifiers.kt`.
## UI & analytics
UI under `impl/.../feature/swap/ui/`: `SwapScreen` (main), `SwapSuccessScreen`,
`SwapScreenContent` (ConstraintLayout card positioning), `TransactionCard`. Cards pass
`TokenSelectionDirection.FROM`/`.TO` to `onSelectTokenClick`.
Analytics: `SwapEvents` sealed hierarchy (`impl/.../analytics/SwapEvents.kt`). Fee tier name
comes from `FeeBucket.toAnalyticsName()` (`Min/Normal/Max/Suggested/Custom`) → `AnalyticsParam.FeeType.fromString(...)`.
## Testing
All domain-layer tests use JUnit 5 + MockK + Truth. Base class `SwapInteractorImplTestBase` wires all ~30 `SwapInteractorImpl` dependencies as relaxed mocks and exposes `sut: SwapInteractorImpl` via `lazy`. Tests extend it and stub only what they need.
Test files by topic:
- `SwapInteractorImplTestBase.kt` — base class; also contains `buildSwapCurrencyStatus(...)` and other builders
- `SwapInteractorImplLoadSwapFeeTest.kt` — unified `loadSwapFee` (all strategy branches: DEX-EVM, DEX-Solana, DEX bridge, CEX gasless-native, CEX gasless-token, CEX explicit-token, null swapData, zero amount)
- `SwapInteractorImplApplySwapFeeTest.kt``applySwapFee` balance/fee-state patching
- `SwapInteractorImplFindBestQuoteTest.kt` — provider dispatch, balance checks
- `SwapInteractorImplLoadDexSwapDataNoFeeTest.kt` — DEX quote-load without fee
- `fee/DexSwapFeeCalculatorTest.kt` — DEX calculator (Solana, EVM, gas fallback, bridge fee)
- `fee/CexSwapFeeCalculatorTest.kt` — CEX calculator (gasless, token, native)
- `fee/SwapFeeFactoryTest.kt``SwapFeeFactory` bucket selection
- `fee/PatchEthGasLimitForSwapTest.kt` — gas limit bump math
- `transfer/SwapTransferInteractorImplTest.kt` — transfer detection and state building
- `impl/StateBuilderInitialStateTest.kt`, `StateBuilderPairsTest.kt` — UI state construction
Domain tests: JUnit 5 + MockK + Truth. Base `SwapInteractorImplTestBase` wires all ~30 deps
as relaxed mocks, exposes `sut` lazily, and holds builders (`buildSwapCurrencyStatus`, …) —
extend it and stub only what you need. Test files mirror topics: `…LoadSwapFeeTest`,
`…ApplySwapFeeTest`, `…FindBestQuoteTest`, `…LoadDexSwapDataNoFeeTest`,
`fee/{Dex,Cex}SwapFeeCalculatorTest`, `fee/SwapFeeFactoryTest`, `fee/PatchEthGasLimitForSwapTest`,
`transfer/SwapTransferInteractorImplTest`, `impl/StateBuilder*Test`.
## Gotchas
**Fee state is transient on DEX.** `loadDexSwapDataNoFee` returns a `QuotesLoadedState` with `feeState = NotEnough()` and `isBalanceEnough = false`. The real values are only set after the fee selector resolves and calls `applySwapFee`. Do not check `preparedSwapConfigState.isBalanceEnough` before the fee selector has emitted a `FeeSelectorUM.Content` state.
**Fee state is transient on DEX.** `loadDexSwapDataNoFee` returns a `QuotesLoadedState` with
`feeState = NotEnough()` and `isBalanceEnough = false`. Real values are only set after the fee
selector resolves and `applySwapFee` runs. Do not check `preparedSwapConfigState.isBalanceEnough`
before the fee selector has emitted `FeeSelectorUM.Content`.
**`SwapFee` is not carried in `SwapProcessDataState`.** It is reconstructed from `feeSelectorRepository.state.value` via `getSelectedSwapFee()` at each call site (swap execution, analytics). `otherNativeFee` must be re-read from `dataState.swapDataModel.transaction` because `FeeSelectorUM` does not carry it.
**`SwapFee` is not stored in `SwapProcessDataState`.** It is reconstructed from
`feeSelectorRepository.state.value` via `getSelectedSwapFee()` at each call site (swap execution,
analytics). `otherNativeFee` must be re-read from `dataState.swapDataModel.transaction` because
`FeeSelectorUM` does not carry it.
**DEX requires pre-fetched `swapDataModel`.** `FeeSelectorRepository.loadFeeExtended` returns `Left(UnknownError)` when `dataState.swapDataModel == null`. This is by design: `manageDex` only calls `loadDexSwapDataNoFee` (which populates `swapDataModel`) when allowance is OK and balance is sufficient. If the user has insufficient balance or a pending approval, the fee selector will not load.
**DEX requires a pre-fetched `swapDataModel`.** `FeeSelectorRepository.loadFeeExtended` returns
`Left(UnknownError)` when `dataState.swapDataModel == null`. By design: `manageDex` only calls
`loadDexSwapDataNoFee` (which populates it) when allowance is OK and balance is sufficient. With
insufficient balance or pending approval, the fee selector will not load.
**`PatchEthGasLimitForSwap` has two instances with different percentages.** DEX uses 12%, CEX uses 5%. They are distinguished by `@SwapDexGasLimit` and `@SwapSendGasLimit` qualifiers. Passing the wrong qualifier to a calculator is a silent bug with no compile-time check.
**Two `PatchEthGasLimitForSwap` instances, different percentages.** DEX 12%, CEX 5%, selected by
`@SwapDexGasLimit` / `@SwapSendGasLimit`. Passing the wrong qualifier is a silent bug — no
compile-time check.
**`Fee.Ethereum.TokenCurrency` throws.** `PatchEthGasLimitForSwap.increaseEthGasLimitInNeeded` calls `error("handle in [REDACTED_TASK_KEY]")` for `TokenCurrency`. This path must not be reached in production. The issue is tracked but not yet resolved.
**`Fee.Ethereum.TokenCurrency` throws.** `PatchEthGasLimitForSwap.increaseEthGasLimitInNeeded`
calls `error("handle in [REDACTED_TASK_KEY]")` for `TokenCurrency`. This path must not be reached in
production (issue tracked, not yet resolved).
**Solana DEX fee is not patched.** Unlike EVM, `DexSwapFeeCalculator` skips `patchEthGasLimitForSwap` for Solana paths. Also: if the compiled transaction exceeds `SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES` and the wallet is `UserWallet.Cold`, the calculator raises `ExpressDataError.TooLargeSolanaTransactionError`.
**Solana DEX fee is not patched.** `DexSwapFeeCalculator` skips `patchEthGasLimitForSwap` on
Solana paths. Also: a compiled tx exceeding `SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES` on a
`UserWallet.Cold` raises `ExpressDataError.TooLargeSolanaTransactionError`.
**`TransactionFeeResult` sealed class is not a data class.** `Loaded(val fee: TransactionFee)` and `LoadedExtended(val fee: TransactionFeeExtended)` use regular `class`, so structural equality does not hold. Use `is`-checks and field comparison in tests.
**`TransactionFeeResult` is not a data class.** `Loaded` / `LoadedExtended` are regular classes,
so structural equality does not hold — use `is`-checks + field comparison in tests.
**`SwapInteractor` interface vs `SwapInteractorImpl`.** The interface exposes `loadSwapFee` and `applySwapFee` (the new unified API). The old `loadFeeForSwapTransaction` overloads (two overloads) and `loadFeeForDex` private method have been fully removed. Do not reference them in new code or tests.
**Don't reference the removed fee API.** The unified API is `loadSwapFee` / `applySwapFee`. The
old `loadFeeForSwapTransaction` overloads, `loadFeeForDex`, `getFeeForCex`, and
`FeeType.getNameForAnalytics()` were removed — do not reintroduce them in new code or tests.
**Transfer mode vs swap mode.** `SwapTransferInteractor.shouldTransferInsteadOfSwap` detects same-wallet same-currency pairs and returns `true`, causing the UI to show `SwapState.Transfer` instead of `SwapState.QuotesLoadedState`. No fee selector is shown in transfer mode.
**Transfer mode vs swap mode.** `shouldTransferInsteadOfSwap` returns `true` for same-wallet
same-currency pairs → UI shows `SwapState.Transfer`, not `QuotesLoadedState`, and no fee selector.

View file

@ -35,9 +35,9 @@ import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.utils.InputNumberFormatter
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase
@ -121,8 +121,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Locale
import javax.inject.Inject
@ -208,10 +206,6 @@ internal class SwapModel @Inject constructor(
appRouter = appRouter,
)
private val inputNumberFormatter = InputNumberFormatter(
NumberFormat.getInstance(Locale.getDefault()) as? DecimalFormat ?: error("NumberFormat is not DecimalFormat"),
)
private val amountDebouncer = Debouncer()
private val transferModeDebouncer = Debouncer()
private val singleTaskScheduler = SingleTaskScheduler<Map<SwapProvider, SwapState>>()
@ -231,6 +225,9 @@ internal class SwapModel @Inject constructor(
private val lastAmount = mutableStateOf(INITIAL_AMOUNT)
private val lastReducedBalanceBy = mutableStateOf(BigDecimal.ZERO)
/** Whether the user is currently entering a fiat amount in the "from" card (vs crypto). */
private val isFiatInput = mutableStateOf(false)
private var userCountry: UserCountry? = null
private val isUserResolvableError: (SwapState) -> Boolean = { swapState ->
@ -515,6 +512,7 @@ internal class SwapModel @Inject constructor(
dataState = if (isFromDirection) {
// Reset amount if from token is changed
lastAmount.value = INITIAL_AMOUNT
isFiatInput.value = false
lastReducedBalanceBy.value = BigDecimal.ZERO
SwapProcessDataState(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
@ -590,6 +588,7 @@ internal class SwapModel @Inject constructor(
isAmountChangedByUser = true
lastAmount.value = INITIAL_AMOUNT
isFiatInput.value = false
lastReducedBalanceBy.value = BigDecimal.ZERO
dataState = SwapProcessDataState(
@ -878,8 +877,28 @@ internal class SwapModel @Inject constructor(
isSilent: Boolean = false,
updateFeeBlock: Boolean = true,
) {
dataState = dataState.copy(
amount = amount,
reduceBalanceBy = reduceBalanceBy,
)
singleTaskScheduler.cancelTask()
if (amount.isBlank()) return
if (amount.isBlank()) {
uiState = stateBuilder.createQuotesEmptyAmountState(
uiStateHolder = uiState,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
emptyAmountState = SwapState.EmptyAmountState(
zeroAmountEquivalent = stringReference(
BigDecimal.ZERO.format {
fiat(
fiatCurrencyCode = selectedAppCurrencyFlow.value.code,
fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol,
)
},
),
),
)
return
}
if (!isSilent) {
uiState = stateBuilder.createQuotesLoadingState(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
@ -966,10 +985,6 @@ internal class SwapModel @Inject constructor(
task = {
uiState = stateBuilder.createSilentLoadState(uiState)
runCatching(dispatchers.default) {
dataState = dataState.copy(
amount = amount,
reduceBalanceBy = reduceBalanceBy,
)
swapInteractor.findBestQuote(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
@ -1612,29 +1627,78 @@ internal class SwapModel @Inject constructor(
.saveIn(if (isFromCurrency) fromTokenBalanceJobHolder else toTokenBalanceJobHolder)
}
private fun onAmountChanged(
value: String,
/**
* Handles raw input from the amount text field. [value] is expressed in the currently active
* input currency (crypto or fiat). The crypto equivalent is always derived and used downstream.
*/
private fun onAmountChanged(value: String) {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return
val fiatRate = fromSwapCurrencyStatus.status.value.fiatRate
val cryptoDecimals = fromSwapCurrencyStatus.currency.decimals
val cryptoValue = if (isFiatInput.value && fiatRate != null) {
value.toCryptoFromFiat(fiatRate, cryptoDecimals)
} else {
value
}
updateAmount(
cryptoValue = cryptoValue,
fieldValue = value,
forceQuotesUpdate = false,
reduceBalanceBy = BigDecimal.ZERO,
isPastedAmount = false,
)
}
/**
* Applies a crypto amount produced programmatically (max / percent / reduce). The visible field
* value is converted to the active input currency for display, while quotes still use crypto.
*/
private fun applyCryptoAmount(
cryptoValue: String,
forceQuotesUpdate: Boolean = false,
reduceBalanceBy: BigDecimal = BigDecimal.ZERO,
) {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val fiatRate = fromSwapCurrencyStatus?.status?.value?.fiatRate
val fieldValue = if (fromSwapCurrencyStatus != null && isFiatInput.value && fiatRate != null) {
cryptoValue.toFiatFromCrypto(fiatRate)
} else {
cryptoValue
}
updateAmount(
cryptoValue = cryptoValue,
fieldValue = fieldValue,
forceQuotesUpdate = forceQuotesUpdate,
reduceBalanceBy = reduceBalanceBy,
isPastedAmount = true,
)
}
private fun updateAmount(
cryptoValue: String,
fieldValue: String,
forceQuotesUpdate: Boolean,
reduceBalanceBy: BigDecimal,
isPastedAmount: Boolean,
) {
modelScope.launch {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
if (fromSwapCurrencyStatus != null) {
val decimals = fromSwapCurrencyStatus.currency.decimals
val cutValue = cutAmountWithDecimals(decimals, value)
val minTxAmount = getMinimumTransactionAmountSyncUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
).getOrNull()
lastAmount.value = cutValue
lastAmount.value = cryptoValue
lastReducedBalanceBy.value = reduceBalanceBy
uiState = stateBuilder.updateSwapAmount(
uiState = uiState,
amountFormatted = inputNumberFormatter.formatWithThousands(cutValue, decimals),
amountRaw = lastAmount.value,
fieldValue = fieldValue,
isFiatValue = isFiatInput.value,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
minTxAmount = minTxAmount,
isPastedAmount = isPastedAmount,
)
if (toSwapCurrencyStatus != null) {
@ -1663,10 +1727,43 @@ internal class SwapModel @Inject constructor(
}
}
/**
* Switches the "from" amount field between crypto and fiat entry. The stored crypto amount stays
* authoritative; only the displayed value and equivalent are recomputed (no quote reload).
*/
private fun onCurrencyChange(isFiat: Boolean) {
if (isFiat == isFiatInput.value) return
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return
val fiatRate = fromSwapCurrencyStatus.status.value.fiatRate
if (isFiat && fiatRate == null) return
isFiatInput.value = isFiat
val cryptoValue = lastAmount.value
val fieldValue = when {
cryptoValue.isEmpty() -> ""
isFiat && fiatRate != null -> cryptoValue.toFiatFromCrypto(fiatRate)
else -> cryptoValue
}
modelScope.launch {
val minTxAmount = getMinimumTransactionAmountSyncUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
).getOrNull()
uiState = stateBuilder.updateSwapAmount(
uiState = uiState,
amountRaw = cryptoValue,
fieldValue = fieldValue,
isFiatValue = isFiat,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
minTxAmount = minTxAmount,
isPastedAmount = false,
)
}
}
private fun onMaxAmountClicked() {
dataState.fromSwapCurrencyStatus?.let { fromCurrency ->
val balance = swapInteractor.getTokenBalance(fromCurrency.status)
onAmountChanged(balance.formatToUIRepresentation())
applyCryptoAmount(balance.formatToUIRepresentation())
}
}
@ -1682,7 +1779,7 @@ internal class SwapModel @Inject constructor(
decimals = fromCurrency.status.currency.decimals,
percent = percent,
)
onAmountChanged(
applyCryptoAmount(
SwapAmount(
value = newValue,
decimals = fromCurrency.status.currency.decimals,
@ -1691,8 +1788,8 @@ internal class SwapModel @Inject constructor(
}
private fun onReduceAmountClicked(newAmount: SwapAmount, reduceBalanceBy: BigDecimal = BigDecimal.ZERO) {
onAmountChanged(
value = newAmount.formatToUIRepresentation(),
applyCryptoAmount(
cryptoValue = newAmount.formatToUIRepresentation(),
forceQuotesUpdate = true,
reduceBalanceBy = reduceBalanceBy,
)
@ -1704,8 +1801,16 @@ internal class SwapModel @Inject constructor(
}
}
private fun cutAmountWithDecimals(maxDecimals: Int, amount: String): String {
return inputNumberFormatter.getValidatedNumberWithFixedDecimals(amount, maxDecimals)
private fun String.toCryptoFromFiat(fiatRate: BigDecimal, cryptoDecimals: Int): String {
return parseToBigDecimal(cryptoDecimals)
.divide(fiatRate, cryptoDecimals, RoundingMode.DOWN)
.parseBigDecimal(cryptoDecimals)
}
private fun String.toFiatFromCrypto(fiatRate: BigDecimal): String {
return parseToBigDecimal(FIAT_DECIMALS)
.multiply(fiatRate)
.parseBigDecimal(FIAT_DECIMALS)
}
private fun showAlert(message: TextReference = resourceReference(R.string.common_unknown_error)) {
@ -1805,6 +1910,7 @@ internal class SwapModel @Inject constructor(
private fun createUiActions(): UiActions {
return UiActions(
onAmountChanged = { onAmountChanged(it) },
onCurrencyChange = { onCurrencyChange(it) },
onSwapClick = {
onSwapClick()
val sendTokenSymbol = dataState.fromSwapCurrencyStatus?.currency?.symbol
@ -2106,6 +2212,7 @@ internal class SwapModel @Inject constructor(
lastReducedBalanceBy.value = BigDecimal.ZERO
lastAmount.value = INITIAL_AMOUNT
isFiatInput.value = false
uiState = stateBuilder.createSwapNotSupportedState(
uiStateHolder = uiState,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
@ -2697,6 +2804,7 @@ internal class SwapModel @Inject constructor(
private companion object {
const val INITIAL_AMOUNT = ""
const val FIAT_DECIMALS = 2
const val UPDATE_DELAY = 10000L
const val DEBOUNCE_AMOUNT_DELAY = 1000L
const val UPDATE_BALANCE_DELAY_MILLIS = 11000L

View file

@ -2,13 +2,14 @@ package com.tangem.feature.swap.models
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.buttons.predefined.PredefinedPercentButtonUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.states.ProviderState
@ -58,15 +59,16 @@ sealed class SwapCardState {
val currencyIconState: CurrencyIconState,
val tokenSymbol: TextReference,
val amountEquivalent: TextReference?,
val amountTextFieldValue: TextFieldValue?,
val balance: String,
val isBalanceHidden: Boolean,
val appCurrency: AppCurrency,
val amountField: AmountFieldModel? = null,
) : SwapCardState()
data class Empty(
override val type: TransactionCardType,
val amountEquivalent: TextReference,
val amountTextFieldValue: TextFieldValue?,
val amountField: AmountFieldModel? = null,
) : SwapCardState()
data class Loading(
@ -99,11 +101,12 @@ sealed interface TransactionCardType {
val inputError: InputError
data class Inputtable(
val onAmountChanged: ((String) -> Unit),
val onFocusChanged: ((Boolean) -> Unit),
override val inputError: InputError,
override val accountTitleUM: AccountTitleUM,
val isEnabled: Boolean,
/** Switches the input field between crypto and fiat entry. Argument is the new `isFiatValue`. */
val onCurrencyChange: (Boolean) -> Unit = {},
) : TransactionCardType
data class ReadOnly(

View file

@ -10,6 +10,7 @@ import java.math.BigDecimal
internal data class UiActions(
val onAmountChanged: (String) -> Unit,
val onCurrencyChange: (Boolean) -> Unit,
val onAmountSelected: (Boolean) -> Unit,
val onSwapClick: () -> Unit,
val onTransferClick: () -> Unit,

View file

@ -1,108 +0,0 @@
package com.tangem.feature.swap.ui
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.ParagraphIntrinsics
import androidx.compose.ui.text.font.createFontFamilyResolver
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.core.ui.res.TangemTheme
@Suppress("MagicNumber", "LongMethod")
@Composable
internal fun AutoSizeTextField(
textFieldValue: TextFieldValue,
focusRequester: FocusRequester,
isEnabled: Boolean,
onAmountChange: (String) -> Unit,
onFocusChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
LaunchedEffect(isEnabled) {
if (!isEnabled) {
focusManager.clearFocus()
}
}
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
var shrunkFontSize = TangemTheme.typography.h2.fontSize
val calculateIntrinsics = @Composable {
ParagraphIntrinsics(
text = textFieldValue.text,
style = TangemTheme.typography.h2.copy(
color = TangemTheme.colors.text.primary1,
fontSize = shrunkFontSize,
),
density = LocalDensity.current,
fontFamilyResolver = createFontFamilyResolver(LocalContext.current),
)
}
var intrinsics = calculateIntrinsics()
with(LocalDensity.current) {
while (intrinsics.maxIntrinsicWidth > maxWidth.toPx()) {
shrunkFontSize *= 0.9f
intrinsics = calculateIntrinsics()
}
}
val customTextSelectionColors = TextSelectionColors(
handleColor = Color.Transparent,
backgroundColor = TangemTheme.colors.text.secondary.copy(alpha = 0.4f),
)
CompositionLocalProvider(LocalTextSelectionColors provides customTextSelectionColors) {
BasicTextField(
value = textFieldValue,
onValueChange = {
onAmountChange.invoke(it.text)
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged { onFocusChange(it.hasFocus) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Decimal,
),
enabled = isEnabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
decorationBox = { innerTextField ->
if (textFieldValue.text.isBlank()) {
Text(
text = "0",
color = TangemTheme.colors.text.disabled,
style = TangemTheme.typography.h2,
)
}
innerTextField()
},
textStyle = TangemTheme.typography.h2.copy(
color = TangemTheme.colors.text.primary1,
fontSize = shrunkFontSize,
),
cursorBrush = SolidColor(TangemTheme.colors.text.primary1),
)
}
}
}

View file

@ -1,13 +1,17 @@
package com.tangem.feature.swap.ui
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverter
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
@ -29,6 +33,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.domain.swap.models.PredefinedPercentAmount
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.converters.SwapProviderStateBuilder
@ -70,6 +75,10 @@ internal class StateBuilder(
) {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
private val amountScreenClickIntents by lazy(LazyThreadSafetyMode.NONE) {
SwapAmountScreenClickIntents(actions)
}
private val notificationsFactory by lazy(LazyThreadSafetyMode.NONE) {
SwapNotificationsFactory(
actions = actions,
@ -196,7 +205,7 @@ internal class StateBuilder(
return uiStateHolder.copy(
sendCardData = uiStateHolder.sendCardData.copy(
type = TransactionCardType.Inputtable(
onAmountChanged = actions.onAmountChanged,
onCurrencyChange = actions.onCurrencyChange,
onFocusChanged = actions.onAmountSelected,
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
@ -274,7 +283,7 @@ internal class StateBuilder(
): SwapCardState {
val cardType = if (isFromCard) {
TransactionCardType.Inputtable(
onAmountChanged = actions.onAmountChanged,
onCurrencyChange = actions.onCurrencyChange,
onFocusChanged = actions.onAmountSelected,
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = getCardAccountTitle(swapCurrencyStatus?.account, true),
@ -295,17 +304,17 @@ internal class StateBuilder(
)
} else if (shouldResetAmount) {
copy(
amountTextFieldValue = if (isFromCard) {
null
} else {
TextFieldValue("0".appendApproximateSign())
},
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status),
tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol),
balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
type = cardType,
amountField = if (isFromCard) {
emptyAmountField(swapCurrencyStatus)
} else {
displayAmountField("0".appendApproximateSign(), swapCurrencyStatus)
},
)
} else {
copy(
@ -314,10 +323,66 @@ internal class StateBuilder(
balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
type = cardType,
amountField = if (isFromCard) {
if (amountField == null) {
emptyAmountField(swapCurrencyStatus)
} else {
buildAmountField(
amountRaw = amountField.cryptoAmount.value?.toPlainString().orEmpty(),
fieldValue = amountField.value,
isFiatValue = amountField.isFiatValue,
fromSwapCurrencyStatus = swapCurrencyStatus,
isPastedAmount = false,
)
}
} else {
amountField
},
)
}
}
private fun emptyAmountField(fromSwapCurrencyStatus: SwapCurrencyStatus): AmountFieldModel = buildAmountField(
amountRaw = "",
fieldValue = "",
isFiatValue = false,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
isPastedAmount = false,
)
/**
* Builds the read-only receive card [AmountFieldModel] from a display [value]. Reuses the existing
* converter path; the read-only UI only reads [AmountFieldModel.value].
*/
private fun displayAmountField(value: String, status: SwapCurrencyStatus): AmountFieldModel = buildAmountField(
amountRaw = "",
fieldValue = value,
isFiatValue = false,
fromSwapCurrencyStatus = status,
isPastedAmount = false,
)
/**
* Builds a status-free placeholder [AmountFieldModel] for the static [SwapCardState.Empty] card,
* which has no [SwapCurrencyStatus]. The Empty card UI only reads [AmountFieldModel.value].
*/
private fun placeholderAmountField(value: String): AmountFieldModel = AmountFieldModel(
value = value,
onValueChange = {},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Number),
keyboardActions = KeyboardActions(),
cryptoAmount = Amount(currencySymbol = "", value = BigDecimal.ZERO, decimals = 0),
fiatAmount = Amount(currencySymbol = "", value = BigDecimal.ZERO, decimals = 0),
isFiatValue = false,
fiatValue = "",
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
isError = false,
isWarning = false,
error = TextReference.EMPTY,
)
private fun createCardState(
swapCurrencyStatus: SwapCurrencyStatus?,
emptyAmountState: SwapState.EmptyAmountState,
@ -330,7 +395,7 @@ internal class StateBuilder(
SwapCardState.SwapCardData(
type = if (isFromCard) {
TransactionCardType.Inputtable(
onAmountChanged = actions.onAmountChanged,
onCurrencyChange = actions.onCurrencyChange,
onFocusChanged = actions.onAmountSelected,
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = getCardAccountTitle(swapCurrencyStatus.account, true),
@ -342,16 +407,17 @@ internal class StateBuilder(
accountTitleUM = getCardAccountTitle(swapCurrencyStatus.account, false),
)
},
amountTextFieldValue = if (isFromCard) {
null
} else {
TextFieldValue("0".appendApproximateSign())
},
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status),
tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol),
balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
amountField = if (isFromCard) {
emptyAmountField(swapCurrencyStatus)
} else {
displayAmountField("0".appendApproximateSign(), swapCurrencyStatus)
},
appCurrency = appCurrencyProvider(),
)
}
}
@ -366,7 +432,7 @@ internal class StateBuilder(
),
),
),
amountTextFieldValue = TextFieldValue(text = if (isFromCard) "0" else "0".appendApproximateSign()),
amountField = placeholderAmountField(value = if (isFromCard) "0" else "0".appendApproximateSign()),
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
)
@ -381,27 +447,25 @@ internal class StateBuilder(
type = TransactionCardType.ReadOnly(
accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
),
amountTextFieldValue = TextFieldValue(
text = "0",
),
amountField = displayAmountField(value = "0", status = fromSwapCurrencyStatus),
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status),
tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol),
balance = fromSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
appCurrency = appCurrencyProvider(),
),
receiveCardData = SwapCardState.SwapCardData(
type = TransactionCardType.ReadOnly(
accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false),
),
amountTextFieldValue = TextFieldValue(
text = "0",
),
amountField = displayAmountField(value = "0", status = toSwapCurrencyStatus),
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status),
tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol),
balance = toSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
appCurrency = appCurrencyProvider(),
),
notifications = notificationsFactory.getSwapNotSupportedNotifications(),
swapButton = SwapButton(
@ -429,7 +493,7 @@ internal class StateBuilder(
return uiStateHolder.copy(
sendCardData = uiStateHolder.sendCardData.copy(
type = TransactionCardType.Inputtable(
onAmountChanged = actions.onAmountChanged,
onCurrencyChange = actions.onCurrencyChange,
onFocusChanged = actions.onAmountSelected,
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
@ -440,7 +504,7 @@ internal class StateBuilder(
type = TransactionCardType.ReadOnly(
accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false),
),
amountTextFieldValue = null,
amountField = null,
amountEquivalent = null,
),
notifications = persistentListOf(),
@ -512,12 +576,13 @@ internal class StateBuilder(
return uiStateHolder.copy(
sendCardData = SwapCardState.SwapCardData(
type = sendInput,
amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue,
amountEquivalent = uiStateHolder.sendCardData.amountEquivalent,
currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status),
tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol),
balance = fromSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
amountField = uiStateHolder.sendCardData.amountField,
appCurrency = appCurrencyProvider(),
),
receiveCardData = SwapCardState.SwapCardData(
type = TransactionCardType.ReadOnly(
@ -525,10 +590,11 @@ internal class StateBuilder(
onWarningClick = actions.onReceiveCardWarningClick,
accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false),
),
amountTextFieldValue = TextFieldValue(
quoteModel.toTokenInfo.tokenAmount
amountField = displayAmountField(
value = quoteModel.toTokenInfo.tokenAmount
.formatToUIRepresentation()
.appendApproximateSign(),
status = toSwapCurrencyStatus,
),
amountEquivalent = if (priceImpact.type.ordinal > PriceImpact.Type.LOW.ordinal) {
combinedReference(
@ -554,6 +620,7 @@ internal class StateBuilder(
tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol),
balance = toSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
appCurrency = appCurrencyProvider(),
),
isInsufficientFunds = isInsufficientFundsCondition(quoteModel),
notifications = notifications,
@ -733,19 +800,18 @@ internal class StateBuilder(
val receiveCardData = toSwapCurrencyStatus?.status?.let { toToken ->
SwapCardState.SwapCardData(
type = type,
amountTextFieldValue = TextFieldValue(
text = "0",
),
amountField = displayAmountField(value = "0", status = toSwapCurrencyStatus),
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status),
tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol),
balance = toToken.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
appCurrency = appCurrencyProvider(),
)
} ?: SwapCardState.Empty(
type = type,
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
amountTextFieldValue = null,
amountField = null,
)
return uiStateHolder.copy(
receiveCardData = receiveCardData,
@ -813,11 +879,10 @@ internal class StateBuilder(
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
return uiStateHolder.copy(
sendCardData = uiStateHolder.sendCardData.copy(
amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue,
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
),
receiveCardData = uiStateHolder.receiveCardData.copy(
amountTextFieldValue = TextFieldValue("0"),
amountField = uiStateHolder.receiveCardData.amountField?.copy(value = "0"),
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
),
notifications = persistentListOf(),
@ -854,19 +919,58 @@ internal class StateBuilder(
)
}
/**
* Builds the shared [AmountFieldModel] that carries the "from" card input state.
*
* @param amountRaw authoritative crypto amount (ungrouped) drives [AmountFieldModel.cryptoAmount].
* @param fieldValue value currently shown in the input field, expressed in the active currency.
* @param isFiatValue whether the active input currency is fiat.
*/
private fun buildAmountField(
amountRaw: String,
fieldValue: String,
isFiatValue: Boolean,
isPastedAmount: Boolean,
fromSwapCurrencyStatus: SwapCurrencyStatus,
): AmountFieldModel {
val appCurrency = appCurrencyProvider()
val fiatRate = fromSwapCurrencyStatus.status.value.fiatRate
val cryptoDecimal = amountRaw.parseBigDecimalOrNull() ?: BigDecimal.ZERO
val fiatValue = fiatRate?.multiply(cryptoDecimal).format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
}
return AmountFieldConverter(
clickIntents = amountScreenClickIntents,
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
appCurrency = appCurrency,
).convert(value = amountRaw).copy(
value = fieldValue,
isFiatValue = isFiatValue,
fiatValue = fiatValue,
isValuePasted = isPastedAmount,
)
}
@Suppress("LongParameterList")
fun updateSwapAmount(
uiState: SwapStateHolder,
amountFormatted: String,
amountRaw: String,
fieldValue: String,
isFiatValue: Boolean,
fromSwapCurrencyStatus: SwapCurrencyStatus,
minTxAmount: BigDecimal?,
isPastedAmount: Boolean,
): SwapStateHolder {
if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState
val amountToSend = amountRaw.parseBigDecimalOrNull()
val currency = fromSwapCurrencyStatus.currency
val fiatRate = fromSwapCurrencyStatus.status.value.fiatRate
val isFiatUnavailable = fiatRate == null
val isFiatEffective = isFiatValue && !isFiatUnavailable
val sendInput = if (minTxAmount != null && amountToSend != null && amountToSend < minTxAmount) {
val minAmountFormatted = minTxAmount.format {
crypto(cryptoCurrency = fromSwapCurrencyStatus.currency, ignoreSymbolPosition = true)
crypto(cryptoCurrency = currency, ignoreSymbolPosition = true)
}
(uiState.sendCardData.type as? TransactionCardType.Inputtable)?.copy(
inputError = TransactionCardType.InputError.WrongAmount,
@ -880,17 +984,22 @@ internal class StateBuilder(
accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
) ?: uiState.sendCardData.type
}
// The secondary line shows the opposite currency: crypto when entering fiat, fiat otherwise.
val amountEquivalent = if (isFiatEffective) {
stringReference(amountToSend.orZero().format { crypto(currency) })
} else {
getFormattedFiatAmount(fiatRate?.let { amountToSend?.multiply(it).orZero() })
}
return uiState.copy(
sendCardData = uiState.sendCardData.copy(
amountTextFieldValue = TextFieldValue(
text = amountFormatted,
selection = TextRange(amountFormatted.length),
),
amountEquivalent = getFormattedFiatAmount(
fromSwapCurrencyStatus.status.value.fiatRate?.let { fiatRate ->
amountToSend?.multiply(fiatRate).orZero()
},
amountField = buildAmountField(
amountRaw = amountRaw,
fieldValue = fieldValue,
isFiatValue = isFiatEffective,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
isPastedAmount = isPastedAmount,
),
amountEquivalent = amountEquivalent,
type = sendInput,
),
)

View file

@ -0,0 +1,27 @@
package com.tangem.feature.swap.ui
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.feature.swap.models.UiActions
/**
* Adapter that exposes legacy swap [UiActions] through the shared [AmountScreenClickIntents] contract
* so the from-card amount field can be constructed by the common `AmountFieldConverter`.
*
* Only the callbacks that the swap amount field actually wires are mapped to real actions; the rest
* are no-ops, because swap-v1 overrides the corresponding [com.tangem.common.ui.amountScreen.models.AmountFieldModel]
* fields (keyboardActions / onValuePastedTriggerDismiss) after conversion to preserve its existing behaviour.
*/
internal class SwapAmountScreenClickIntents(
private val actions: UiActions,
) : AmountScreenClickIntents {
override fun onAmountValueChange(value: String) = actions.onAmountChanged(value)
override fun onAmountPasteTriggerDismiss() = Unit
override fun onMaxValueClick() = actions.onMaxAmountSelected()
override fun onCurrencyChangeClick(isFiat: Boolean) = actions.onCurrencyChange(isFiat)
override fun onAmountNext() = Unit
}

View file

@ -2,6 +2,7 @@ package com.tangem.feature.swap.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@ -14,13 +15,16 @@ import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -35,9 +39,13 @@ import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_arrow_swap_horizontal_16
import com.tangem.core.ui.test.SwapTokenScreenTestTags
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.SwapCardState
@ -110,9 +118,7 @@ private fun TransactionCardData(
)
Content(
type = cardState.type,
amountEquivalent = cardState.amountEquivalent,
textFieldValue = cardState.amountTextFieldValue,
cardData = cardState,
priceImpact = priceImpact,
)
}
@ -177,7 +183,7 @@ private fun TransactionCardEmpty(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = cardState.amountTextFieldValue?.text.orEmpty(),
text = cardState.amountField?.value.orEmpty(),
color = TangemTheme.colors.text.disabled,
style = TangemTheme.typography.h2,
autoSize = TextAutoSize.StepBased(
@ -323,12 +329,9 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie
@Suppress("LongMethod")
@Composable
private fun Content(
type: TransactionCardType,
amountEquivalent: TextReference?,
priceImpact: PriceImpact,
textFieldValue: TextFieldValue?,
) {
private fun Content(cardData: SwapCardState.SwapCardData, priceImpact: PriceImpact) {
val type = cardData.type
val amountEquivalent = cardData.amountEquivalent
Row(
modifier = Modifier
.padding(
@ -349,9 +352,10 @@ private fun Content(
val sumTextModifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size32)
when (type) {
is TransactionCardType.ReadOnly -> {
if (textFieldValue != null) {
val value = cardData.amountField?.value
if (value != null) {
Text(
text = textFieldValue.text,
text = value,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.h2,
autoSize = TextAutoSize.StepBased(
@ -371,77 +375,25 @@ private fun Content(
}
}
is TransactionCardType.Inputtable -> {
val focusRequester = remember { FocusRequester() }
AutoSizeTextField(
modifier = sumTextModifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
focusRequester = focusRequester,
textFieldValue = textFieldValue ?: TextFieldValue(),
isEnabled = type.isEnabled,
onAmountChange = { type.onAmountChanged(it) },
onFocusChange = type.onFocusChanged,
)
LaunchedEffect(type.isEnabled) {
if (type.isEnabled) {
focusRequester.requestFocus()
} else {
focusRequester.freeFocus()
}
}
AmountInputField(cardData = cardData, type = type, modifier = sumTextModifier)
}
}
SpacerH4()
if (amountEquivalent != null) {
if (type is TransactionCardType.ReadOnly) {
Row(
modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20),
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
Text(
text = amount.resolveAnnotatedReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT),
)
}
if (type.shouldShowWarning) {
SpacerW4()
IconButton(
onClick = {
type.onWarningClick?.invoke()
},
modifier = Modifier.size(size = TangemTheme.dimens.size20),
) {
Icon(
painter = painterResource(id = R.drawable.ic_information_24),
contentDescription = null,
tint = when (priceImpact.type) {
PriceImpact.Type.HIGH -> TangemTheme.colors.text.warning
PriceImpact.Type.MEDIUM -> TangemTheme.colors.text.attention
else -> TangemTheme.colors.text.tertiary
},
modifier = Modifier
.align(Alignment.CenterVertically)
.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT_INFORMATION_ICON),
)
}
}
}
} else {
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
Text(
text = amount.resolveAnnotatedReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier
.defaultMinSize(minHeight = TangemTheme.dimens.size20)
.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
)
}
when (type) {
is TransactionCardType.ReadOnly -> ReceiveAmountEquivalent(
amountEquivalent = amountEquivalent,
type = type,
priceImpact = priceImpact,
)
is TransactionCardType.Inputtable -> SwapAmountEquivalent(
amountEquivalent = amountEquivalent,
isFiatValue = cardData.amountField?.isFiatValue == true,
isFiatUnavailable = cardData.amountField?.isFiatUnavailable == true,
onCurrencyChange = type.onCurrencyChange,
)
}
} else {
RectangleShimmer(
@ -457,6 +409,147 @@ private fun Content(
}
}
@Composable
internal fun AmountInputField(
cardData: SwapCardState.SwapCardData,
type: TransactionCardType.Inputtable,
modifier: Modifier = Modifier,
) {
val amountField = cardData.amountField ?: return
val focusRequester = remember { FocusRequester() }
val activeAmount = if (amountField.isFiatValue) {
amountField.fiatAmount
} else {
amountField.cryptoAmount
}
AmountTextField(
value = amountField.value,
decimals = activeAmount.decimals,
onValueChange = amountField.onValueChange,
textStyle = TangemTheme.typography.h2.copy(color = TangemTheme.colors.text.primary1),
isEnabled = type.isEnabled,
isAutoResize = true,
visualTransformation = AmountVisualTransformation(
currencyCode = cardData.appCurrency.code.takeIf { amountField.isFiatValue },
symbol = activeAmount.currencySymbol.takeIf { amountField.isFiatValue },
decimals = activeAmount.decimals,
symbolColor = TangemTheme.colors.text.disabled,
),
isValuePasted = amountField.isValuePasted,
onValuePastedTriggerDismiss = amountField.onValuePastedTriggerDismiss,
backgroundColor = TangemTheme.colors.background.primary,
keyboardOptions = amountField.keyboardOptions,
keyboardActions = amountField.keyboardActions,
modifier = modifier
.focusRequester(focusRequester)
.onFocusChanged { type.onFocusChanged(it.hasFocus) }
.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
)
LaunchedEffect(type.isEnabled) {
if (type.isEnabled) {
focusRequester.requestFocus()
} else {
focusRequester.freeFocus()
}
}
}
@Composable
private fun ReceiveAmountEquivalent(
amountEquivalent: TextReference,
type: TransactionCardType.ReadOnly,
priceImpact: PriceImpact,
) {
Row(
modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20),
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
Text(
text = amount.resolveAnnotatedReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT),
)
}
if (type.shouldShowWarning) {
SpacerW4()
IconButton(
onClick = { type.onWarningClick?.invoke() },
modifier = Modifier.size(size = TangemTheme.dimens.size20),
) {
Icon(
painter = painterResource(id = R.drawable.ic_information_24),
contentDescription = null,
tint = when (priceImpact.type) {
PriceImpact.Type.HIGH -> TangemTheme.colors.text.warning
PriceImpact.Type.MEDIUM -> TangemTheme.colors.text.attention
else -> TangemTheme.colors.text.tertiary
},
modifier = Modifier
.align(Alignment.CenterVertically)
.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT_INFORMATION_ICON),
)
}
}
}
}
private const val CURRENCY_TOGGLE_ROTATED_DEGREE = 180f
private const val CURRENCY_TOGGLE_INITIAL_DEGREE = 0f
@Composable
private fun SwapAmountEquivalent(
amountEquivalent: TextReference,
isFiatValue: Boolean,
isFiatUnavailable: Boolean,
onCurrencyChange: (Boolean) -> Unit,
) {
val rowModifier = Modifier
.defaultMinSize(minHeight = TangemTheme.dimens.size20)
.then(
if (isFiatUnavailable) {
Modifier
} else {
Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { onCurrencyChange(!isFiatValue) },
)
},
)
Row(
modifier = rowModifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
) {
if (!isFiatUnavailable) {
val iconRotation by animateFloatAsState(
targetValue = if (isFiatValue) CURRENCY_TOGGLE_ROTATED_DEGREE else CURRENCY_TOGGLE_INITIAL_DEGREE,
label = "Currency toggle icon rotation",
)
Icon(
imageVector = Icons.ic_arrow_swap_horizontal_16,
contentDescription = null,
tint = TangemTheme.colors3.icon.tertiary,
modifier = Modifier
.size(TangemTheme.dimens.size16)
.graphicsLayer { rotationZ = iconRotation },
)
}
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
Text(
text = amount.resolveAnnotatedReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
)
}
}
}
@Suppress("MagicNumber")
@Composable
fun Token(currencyIconState: CurrencyIconState, tokenSymbol: TextReference) {

View file

@ -13,14 +13,11 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -104,8 +101,7 @@ private fun SimpleTransactionCardData(
)
SimpleContent(
type = cardState.type,
textFieldValue = cardState.amountTextFieldValue,
cardData = cardState,
priceImpact = priceImpact,
)
}
@ -170,7 +166,7 @@ private fun SimpleTransactionCardEmpty(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = cardState.amountTextFieldValue?.text.orEmpty(),
text = cardState.amountField?.value.orEmpty(),
color = TangemTheme.colors.text.disabled,
style = TangemTheme.typography.h2,
autoSize = TextAutoSize.StepBased(
@ -308,7 +304,8 @@ private fun SimpleHeader(type: TransactionCardType, balance: String, modifier: M
@Suppress("LongMethod")
@Composable
private fun SimpleContent(type: TransactionCardType, priceImpact: PriceImpact, textFieldValue: TextFieldValue?) {
private fun SimpleContent(cardData: SwapCardState.SwapCardData, priceImpact: PriceImpact) {
val type = cardData.type
Row(
modifier = Modifier
.padding(
@ -326,9 +323,10 @@ private fun SimpleContent(type: TransactionCardType, priceImpact: PriceImpact, t
val sumTextModifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size32)
when (type) {
is TransactionCardType.ReadOnly -> {
if (textFieldValue != null) {
val value = cardData.amountField?.value
if (value != null) {
Text(
text = textFieldValue.text,
text = value,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.h2,
autoSize = TextAutoSize.StepBased(
@ -348,16 +346,7 @@ private fun SimpleContent(type: TransactionCardType, priceImpact: PriceImpact, t
}
}
is TransactionCardType.Inputtable -> {
val focusRequester = remember { FocusRequester() }
AutoSizeTextField(
modifier = sumTextModifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
focusRequester = focusRequester,
textFieldValue = textFieldValue ?: TextFieldValue(),
isEnabled = type.isEnabled,
onAmountChange = { type.onAmountChanged(it) },
onFocusChange = type.onFocusChanged,
)
LaunchedEffect(Unit) { focusRequester.requestFocus() }
AmountInputField(cardData = cardData, type = type, modifier = sumTextModifier)
}
}
SpacerH4()

View file

@ -1,22 +1,30 @@
package com.tangem.feature.swap.ui.preview
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.common.ui.account.AccountNameUM
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.feature.swap.models.SwapCardState
import com.tangem.feature.swap.models.TransactionCardType
import com.tangem.feature.swap.presentation.R
import java.math.BigDecimal
internal object SwapTransactionCardPreview {
val sendCard = SwapCardState.SwapCardData(
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = AccountTitleUM.Account(
@ -26,12 +34,33 @@ internal object SwapTransactionCardPreview {
),
isEnabled = true,
),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = stringReference("1 000 000"),
currencyIconState = CurrencyIconState.Loading,
tokenSymbol = stringReference("DAI"),
balance = "123123123.123123",
isBalanceHidden = false,
appCurrency = AppCurrency.Default,
amountField = AmountFieldModel(
value = "100",
onValueChange = {},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Number),
keyboardActions = KeyboardActions(),
cryptoAmount = Amount(currencySymbol = "DAI", value = BigDecimal("100"), decimals = 18),
fiatAmount = Amount(
currencySymbol = "$",
value = BigDecimal("100"),
decimals = 2,
type = AmountType.FiatType("USD"),
),
isFiatValue = false,
fiatValue = "$100.00",
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
isError = false,
isWarning = false,
error = TextReference.EMPTY,
),
)
val receiveCard = SwapCardState.SwapCardData(
@ -42,12 +71,33 @@ internal object SwapTransactionCardPreview {
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = stringReference("1 000 000"),
currencyIconState = CurrencyIconState.Loading,
tokenSymbol = stringReference("DAI"),
balance = "33333",
isBalanceHidden = false,
appCurrency = AppCurrency.Default,
amountField = AmountFieldModel(
value = "100",
onValueChange = {},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Number),
keyboardActions = KeyboardActions(),
cryptoAmount = Amount(currencySymbol = "DAI", value = BigDecimal("100"), decimals = 18),
fiatAmount = Amount(
currencySymbol = "$",
value = BigDecimal("100"),
decimals = 2,
type = AmountType.FiatType("USD"),
),
isFiatValue = false,
fiatValue = "$100.00",
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
isError = false,
isWarning = false,
error = TextReference.EMPTY,
),
)
val emptyReadOnlyCard = SwapCardState.Empty(
@ -55,24 +105,22 @@ internal object SwapTransactionCardPreview {
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)),
),
amountEquivalent = stringReference("$0.00"),
amountTextFieldValue = null,
amountField = null,
)
val emptyInputtableCard = SwapCardState.Empty(
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_from_title)),
isEnabled = false,
),
amountEquivalent = stringReference("$0.00"),
amountTextFieldValue = null,
amountField = null,
)
val loadingCard = SwapCardState.Loading(
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)),

View file

@ -1,11 +1,16 @@
package com.tangem.feature.swap.ui.transfer
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.foundation.text.KeyboardActions
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.ui.account.AccountIconUM
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverter
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
@ -26,12 +31,14 @@ import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.model.SwapProcessDataState
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.ui.SwapAmountScreenClickIntents
import com.tangem.feature.swap.models.SwapButton.Mode
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.presentation.R
import com.tangem.features.send.api.utils.formatFooterFiatFee
import com.tangem.features.send.api.utils.getTronTokenFeeSendingText
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.ImmutableList
import java.math.BigDecimal
import javax.inject.Inject
@ -54,7 +61,9 @@ internal class SwapTransferStateBuilder @Inject constructor(
val fromTokenSwapInfo = transferState.fromTokenInfo
val toTokenSwapInfo = transferState.toTokenInfo
val isInsufficientBalance = transferState.isInsufficientBalance
val amountTextFieldValue = (uiStateHolder.sendCardData as? SwapCardState.SwapCardData)?.amountTextFieldValue
val prevSendCard = uiStateHolder.sendCardData as? SwapCardState.SwapCardData
val prevAmountField = prevSendCard?.amountField
val displayValue = prevAmountField?.value.orEmpty()
val notifications = notificationsFactory.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
@ -66,17 +75,18 @@ internal class SwapTransferStateBuilder @Inject constructor(
return uiStateHolder.copy(
sendCardData = createSendSwapCardState(
actions = actions,
amountTextFieldValue = amountTextFieldValue,
displayValue = displayValue,
tokenSwapInfo = fromTokenSwapInfo,
appCurrency = transferState.appCurrency,
isAccountsMode = transferState.isAccountsMode,
isFromCard = true,
isBalanceHidden = transferState.isBalanceHidden,
isInsufficientBalance = isInsufficientBalance,
prevAmountField = prevAmountField,
),
receiveCardData = createSendSwapCardState(
actions = actions,
amountTextFieldValue = amountTextFieldValue,
displayValue = displayValue,
tokenSwapInfo = toTokenSwapInfo,
appCurrency = transferState.appCurrency,
isAccountsMode = transferState.isAccountsMode,
@ -99,15 +109,17 @@ internal class SwapTransferStateBuilder @Inject constructor(
@Suppress("LongParameterList")
private fun createSendSwapCardState(
actions: UiActions,
amountTextFieldValue: TextFieldValue?,
displayValue: String,
tokenSwapInfo: TokenSwapInfo,
appCurrency: AppCurrency,
isAccountsMode: Boolean,
isFromCard: Boolean,
isBalanceHidden: Boolean,
isInsufficientBalance: Boolean,
prevAmountField: AmountFieldModel? = null,
): SwapCardState {
val swapCurrencyStatus = tokenSwapInfo.swapCurrencyStatus
val currency = swapCurrencyStatus.currency
return SwapCardState.SwapCardData(
type = createSendTransactionCardType(
@ -120,14 +132,89 @@ internal class SwapTransferStateBuilder @Inject constructor(
currencyIconState = iconConverter.convert(
value = swapCurrencyStatus.status,
),
tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol),
tokenSymbol = stringReference(currency.symbol),
amountEquivalent = getFormattedFiatAmount(
appCurrency = appCurrency,
amount = tokenSwapInfo.amountFiat,
),
amountTextFieldValue = amountTextFieldValue,
balance = swapCurrencyStatus.status.getFormattedAmount(),
isBalanceHidden = isBalanceHidden,
appCurrency = appCurrency,
amountField = if (isFromCard) {
buildAmountField(
actions = actions,
prevAmountField = prevAmountField,
swapCurrencyStatus = swapCurrencyStatus,
appCurrency = appCurrency,
)
} else {
// Read-only receive card mirrors the same display value the "from" card shows in transfer mode.
displayAmountField(
actions = actions,
value = displayValue,
swapCurrencyStatus = swapCurrencyStatus,
appCurrency = appCurrency,
)
},
)
}
/**
* Builds the read-only receive card [AmountFieldModel] in transfer mode from a display [value].
* The read-only UI only reads [AmountFieldModel.value].
*/
private fun displayAmountField(
actions: UiActions,
value: String,
swapCurrencyStatus: SwapCurrencyStatus,
appCurrency: AppCurrency,
): AmountFieldModel = AmountFieldConverter(
clickIntents = SwapAmountScreenClickIntents(actions),
cryptoCurrencyStatus = swapCurrencyStatus.status,
appCurrency = appCurrency,
).convert(value = "").copy(
value = value,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(),
onValuePastedTriggerDismiss = {},
)
/**
* Rebuilds the "from" card [AmountFieldModel] in transfer mode, preserving the previously entered
* value and the crypto/fiat toggle while refreshing currency-derived fields against the latest status.
*/
private fun buildAmountField(
actions: UiActions,
prevAmountField: AmountFieldModel?,
swapCurrencyStatus: SwapCurrencyStatus,
appCurrency: AppCurrency,
): AmountFieldModel {
val fiatRate = swapCurrencyStatus.status.value.fiatRate
val isFiatValue = prevAmountField?.isFiatValue == true && fiatRate != null
val cryptoDecimal = prevAmountField?.cryptoAmount?.value.orZero()
val fiatDecimal = fiatRate?.multiply(cryptoDecimal)
// The converter is the single source for cryptoAmount / fiatAmount construction (FIAT_DECIMALS = 2).
// The previously entered value + crypto/fiat toggle display are restored afterwards via copy(...),
// keeping the resulting AmountFieldModel field-for-field equivalent to the prior hand-rolled builder.
return AmountFieldConverter(
clickIntents = SwapAmountScreenClickIntents(actions),
cryptoCurrencyStatus = swapCurrencyStatus.status,
appCurrency = appCurrency,
).convert(value = cryptoDecimal.toPlainString()).copy(
value = prevAmountField?.value.orEmpty(),
isFiatValue = isFiatValue,
fiatValue = fiatDecimal?.format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
}.orEmpty(),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(),
onValuePastedTriggerDismiss = {},
)
}
@ -149,7 +236,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
)
}
TransactionCardType.Inputtable(
onAmountChanged = actions.onAmountChanged,
onCurrencyChange = actions.onCurrencyChange,
onFocusChanged = actions.onAmountSelected,
inputError = if (isInsufficientBalance) {
TransactionCardType.InputError.InsufficientFunds

View file

@ -0,0 +1,302 @@
package com.tangem.feature.swap
import com.google.common.truth.Truth.assertThat
import com.tangem.common.routing.AppRouter
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.domain.models.ui.SwapState
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.ui.StateBuilder
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.Provider
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class StateBuilderUpdateSwapAmountTest {
private val actions: UiActions = mockk(relaxed = true)
private val isBalanceHiddenProvider: Provider<Boolean> = mockk()
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = mockk()
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
private val appRouter: AppRouter = mockk()
private val appCurrency = AppCurrency.Default
private val userWalletId = UserWalletId("aabbccdd")
private val coldWallet: UserWallet.Cold = mockk(relaxed = true) {
every { walletId } returns userWalletId
}
private lateinit var sut: StateBuilder
@BeforeEach
fun setup() {
every { isBalanceHiddenProvider() } returns false
every { appCurrencyProvider() } returns appCurrency
every { isAccountsModeProvider() } returns false
sut = StateBuilder(
actions = actions,
isBalanceHiddenProvider = isBalanceHiddenProvider,
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
swapFeatureToggles = swapFeatureToggles,
appRouter = appRouter,
)
}
private fun readyState(fromStatus: SwapCurrencyStatus): SwapStateHolder = sut.createInitialReadyState(
uiStateHolder = sut.createInitialLoadingState(),
emptyAmountState = SwapState.EmptyAmountState(zeroAmountEquivalent = stringReference("$0.00")),
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = buildSwapCurrencyStatus(coldWallet),
)
private val SwapStateHolder.sendCard: SwapCardState.SwapCardData
get() = sendCardData as SwapCardState.SwapCardData
@Test
fun `GIVEN crypto input WHEN updateSwapAmount THEN field shows crypto value and equivalent is fiat`() {
// Arrange
val fromStatus = buildSwapCurrencyStatus(coldWallet) // fiatRate = 2000
val base = readyState(fromStatus)
// Act
val result = sut.updateSwapAmount(
uiState = base,
amountRaw = "0.5",
fieldValue = "0.5",
isFiatValue = false,
fromSwapCurrencyStatus = fromStatus,
minTxAmount = null,
isPastedAmount = false,
)
// Assert
val field = result.sendCard.amountField!!
assertThat(field.value).isEqualTo("0.5")
assertThat(field.isFiatValue).isFalse()
assertThat(field.cryptoAmount.value).isEqualTo(BigDecimal("0.5"))
assertThat(field.isValuePasted).isFalse()
// 0.5 * 2000 = 1000 fiat
assertThat(result.sendCard.amountEquivalent).isEqualTo(
stringReference(
BigDecimal("1000.00").format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
},
),
)
}
@Test
fun `GIVEN fiat input WHEN updateSwapAmount THEN field marked fiat and equivalent is crypto`() {
// Arrange
val fromStatus = buildSwapCurrencyStatus(coldWallet) // fiatRate = 2000
val base = readyState(fromStatus)
// Act
val result = sut.updateSwapAmount(
uiState = base,
amountRaw = "0.5", // crypto authoritative amount
fieldValue = "1000", // displayed fiat value
isFiatValue = true,
fromSwapCurrencyStatus = fromStatus,
minTxAmount = null,
isPastedAmount = false,
)
// Assert
val field = result.sendCard.amountField!!
assertThat(field.value).isEqualTo("1000")
assertThat(field.isFiatValue).isTrue()
assertThat(field.cryptoAmount.value).isEqualTo(BigDecimal("0.5"))
// equivalent line shows the crypto amount when entering fiat
assertThat(result.sendCard.amountEquivalent).isEqualTo(
stringReference(BigDecimal("0.5").format { crypto(fromStatus.currency) }),
)
}
@Test
fun `GIVEN fiat input but fiat rate unavailable WHEN updateSwapAmount THEN field falls back to crypto display`() {
// Arrange
val fromStatus = buildSwapCurrencyStatusNoFiatRate(coldWallet)
val base = readyState(buildSwapCurrencyStatus(coldWallet))
// Act
val result = sut.updateSwapAmount(
uiState = base,
amountRaw = "0.5",
fieldValue = "0.5",
isFiatValue = true,
fromSwapCurrencyStatus = fromStatus,
minTxAmount = null,
isPastedAmount = false,
)
// Assert
val field = result.sendCard.amountField!!
// isFiatValue collapses to false because the rate is unavailable
assertThat(field.isFiatValue).isFalse()
assertThat(field.isFiatUnavailable).isTrue()
}
@Test
fun `GIVEN amount below min WHEN updateSwapAmount THEN send card reports WrongAmount error`() {
// Arrange
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val base = readyState(fromStatus)
// Act
val result = sut.updateSwapAmount(
uiState = base,
amountRaw = "0.5",
fieldValue = "0.5",
isFiatValue = false,
fromSwapCurrencyStatus = fromStatus,
minTxAmount = BigDecimal("1"),
isPastedAmount = false,
)
// Assert
val inputtable = result.sendCard.type as TransactionCardType.Inputtable
assertThat(inputtable.inputError).isEqualTo(TransactionCardType.InputError.WrongAmount)
}
@Test
fun `GIVEN amount at or above min WHEN updateSwapAmount THEN send card has no input error`() {
// Arrange
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val base = readyState(fromStatus)
// Act
val result = sut.updateSwapAmount(
uiState = base,
amountRaw = "2",
fieldValue = "2",
isFiatValue = false,
fromSwapCurrencyStatus = fromStatus,
minTxAmount = BigDecimal("1"),
isPastedAmount = false,
)
// Assert
val inputtable = result.sendCard.type as TransactionCardType.Inputtable
assertThat(inputtable.inputError).isEqualTo(TransactionCardType.InputError.Empty)
}
@Test
fun `GIVEN pasted amount WHEN updateSwapAmount THEN field flags value as pasted`() {
// Arrange
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val base = readyState(fromStatus)
// Act
val result = sut.updateSwapAmount(
uiState = base,
amountRaw = "0.5",
fieldValue = "0.5",
isFiatValue = false,
fromSwapCurrencyStatus = fromStatus,
minTxAmount = null,
isPastedAmount = true,
)
// Assert
assertThat(result.sendCard.amountField!!.isValuePasted).isTrue()
}
@Test
fun `GIVEN send card is not SwapCardData WHEN updateSwapAmount THEN uiState returned unchanged`() {
// Arrange
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val loadingState = sut.createInitialLoadingState() // send card is Empty, not SwapCardData
// Act
val result = sut.updateSwapAmount(
uiState = loadingState,
amountRaw = "0.5",
fieldValue = "0.5",
isFiatValue = false,
fromSwapCurrencyStatus = fromStatus,
minTxAmount = null,
isPastedAmount = false,
)
// Assert
assertThat(result).isSameInstanceAs(loadingState)
}
@Test
fun `GIVEN ready state WHEN createQuotesEmptyAmountState THEN receive amount resets to zero and button disabled`() {
// Arrange
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val base = sut.updateSwapAmount(
uiState = readyState(fromStatus),
amountRaw = "0.5",
fieldValue = "0.5",
isFiatValue = false,
fromSwapCurrencyStatus = fromStatus,
minTxAmount = null,
isPastedAmount = false,
)
val zeroEquivalent = stringReference("$0.00")
// Act
val result = sut.createQuotesEmptyAmountState(
uiStateHolder = base,
emptyAmountState = SwapState.EmptyAmountState(zeroAmountEquivalent = zeroEquivalent),
fromSwapCurrencyStatus = fromStatus,
)
// Assert
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
assertThat(receiveCard.amountField!!.value).isEqualTo("0")
assertThat(result.sendCard.amountEquivalent).isEqualTo(zeroEquivalent)
assertThat(receiveCard.amountEquivalent).isEqualTo(zeroEquivalent)
assertThat(result.swapButton.isEnabled).isFalse()
assertThat(result.isInsufficientFunds).isFalse()
assertThat(result.notifications).isEmpty()
}
@Test
fun `GIVEN receive card is not SwapCardData WHEN createQuotesEmptyAmountState THEN uiState returned unchanged`() {
// Arrange
val fromStatus = buildSwapCurrencyStatus(coldWallet)
// createInitialReadyState builds SwapCardData send + SwapCardData receive, but loading state has Empty cards
val loadingState = sut.createInitialLoadingState()
// Act
val result = sut.createQuotesEmptyAmountState(
uiStateHolder = loadingState,
emptyAmountState = SwapState.EmptyAmountState(zeroAmountEquivalent = stringReference("$0.00")),
fromSwapCurrencyStatus = fromStatus,
)
// Assert
assertThat(result).isSameInstanceAs(loadingState)
}
private fun buildSwapCurrencyStatusNoFiatRate(userWallet: UserWallet): SwapCurrencyStatus {
val status = buildSwapCurrencyStatus(userWallet)
every { status.status.value.fiatRate } returns null
return status
}
}

View file

@ -0,0 +1,82 @@
package com.tangem.feature.swap.ui
import com.google.common.truth.Truth.assertThat
import com.tangem.feature.swap.models.UiActions
import org.junit.jupiter.api.Test
// PER_METHOD (the JUnit5 default): a fresh instance per test, so the recorded-call fields never leak.
internal class SwapAmountScreenClickIntentsTest {
private var changedValue: String? = null
private var maxClicked = false
private var currencyChangeIsFiat: Boolean? = null
// Real UiActions: the three wired callbacks record their invocation, the rest are no-ops.
private val actions = UiActions(
onAmountChanged = { changedValue = it },
onCurrencyChange = { currencyChangeIsFiat = it },
onAmountSelected = {},
onSwapClick = {},
onTransferClick = {},
onChangeCardsClicked = {},
onBackClicked = {},
onMaxAmountSelected = { maxClicked = true },
onPredefinedPercentSelected = {},
onReduceToAmount = {},
onReduceByAmount = { _, _ -> },
onApproveClick = {},
onApproveTypeSelect = {},
onRetryClick = {},
onProviderClick = {},
onProviderSelect = {},
onProviderFilterSelect = {},
openTokenDetailsScreen = {},
onSelectTokenClick = {},
onSuccess = {},
onLinkClick = {},
onReceiveCardWarningClick = {},
onSwapUIModeChange = {},
onSwapTypeMenuOpened = {},
)
private val sut = SwapAmountScreenClickIntents(actions)
@Test
fun `GIVEN value WHEN onAmountValueChange THEN delegates to onAmountChanged`() {
// Act
sut.onAmountValueChange("12.34")
// Assert
assertThat(changedValue).isEqualTo("12.34")
}
@Test
fun `GIVEN max clicked WHEN onMaxValueClick THEN delegates to onMaxAmountSelected`() {
// Act
sut.onMaxValueClick()
// Assert
assertThat(maxClicked).isTrue()
}
@Test
fun `GIVEN fiat toggle WHEN onCurrencyChangeClick THEN delegates to onCurrencyChange`() {
// Act
sut.onCurrencyChangeClick(isFiat = true)
// Assert
assertThat(currencyChangeIsFiat).isTrue()
}
@Test
fun `GIVEN paste dismiss and next WHEN invoked THEN they are no-ops and do not delegate`() {
// Act
sut.onAmountPasteTriggerDismiss()
sut.onAmountNext()
// Assert — none of the wired callbacks fired
assertThat(changedValue).isNull()
assertThat(maxClicked).isFalse()
assertThat(currencyChangeIsFiat).isNull()
}
}

View file

@ -1,13 +1,16 @@
package com.tangem.feature.swap.ui.transfer
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.ui.extensions.TextReference
@ -77,9 +80,31 @@ internal class SwapTransferStateBuilderTest {
private val iconConverter = CryptoCurrencyToIconStateConverter()
private val fromIcon = iconConverter.convert(fromCurrencyStatus.status)
private val toIcon = iconConverter.convert(toCurrencyStatus.status)
private val initialAmountTextFieldValue = TextFieldValue(
text = "0.5",
selection = TextRange(index = 3),
private val initialAmountValue = "0.5"
private val initialAmountField = AmountFieldModel(
value = initialAmountValue,
onValueChange = {},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Number),
keyboardActions = KeyboardActions(),
cryptoAmount = com.tangem.domain.tokens.model.Amount(
currencySymbol = "",
value = BigDecimal("0.5"),
decimals = 18,
),
fiatAmount = com.tangem.domain.tokens.model.Amount(
currencySymbol = "$",
value = BigDecimal("0.5"),
decimals = 2,
type = com.tangem.domain.tokens.model.AmountType.FiatType("USD"),
),
isFiatValue = false,
fiatValue = "",
isFiatUnavailable = false,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
isError = false,
isWarning = false,
error = TextReference.EMPTY,
)
@Test
@ -104,7 +129,8 @@ internal class SwapTransferStateBuilderTest {
val expectedAccountIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
val expectedAccountName = portfolioAccount.accountName.toUM().value
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
val receiveType =
(result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.swapping_from_account_title),
@ -154,7 +180,8 @@ internal class SwapTransferStateBuilderTest {
)
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
val receiveType =
(result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_from_title_v2)),
)
@ -197,7 +224,8 @@ internal class SwapTransferStateBuilderTest {
)
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
val receiveType =
(result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_insufficient_funds)),
)
@ -243,7 +271,8 @@ internal class SwapTransferStateBuilderTest {
val expectedAccountIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
val expectedAccountName = portfolioAccount.accountName.toUM().value
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
val receiveType =
(result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_insufficient_funds)),
)
@ -684,8 +713,8 @@ internal class SwapTransferStateBuilderTest {
) {
val sendCard = result.sendCardData as SwapCardState.SwapCardData
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
assertThat(sendCard.amountTextFieldValue).isEqualTo(initialAmountTextFieldValue)
assertThat(receiveCard.amountTextFieldValue).isEqualTo(initialAmountTextFieldValue)
assertThat(sendCard.amountField?.value).isEqualTo(initialAmountValue)
assertThat(receiveCard.amountField?.value).isEqualTo(initialAmountValue)
assertThat(sendCard.currencyIconState).isEqualTo(fromIcon)
assertThat(receiveCard.currencyIconState).isEqualTo(toIcon)
assertThat(sendCard.isBalanceHidden).isEqualTo(transferState.isBalanceHidden)
@ -742,8 +771,8 @@ internal class SwapTransferStateBuilderTest {
private fun baseStateHolder(): SwapStateHolder = SwapStateHolder(
sendCardData = SwapCardState.SwapCardData(
appCurrency = AppCurrency.Default,
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = AccountTitleUM.Text(resourceReference(R.string.swapping_from_title_v2)),
@ -752,7 +781,7 @@ internal class SwapTransferStateBuilderTest {
currencyIconState = fromIcon,
tokenSymbol = stringReference(""),
amountEquivalent = TextReference.EMPTY,
amountTextFieldValue = initialAmountTextFieldValue,
amountField = initialAmountField,
balance = "",
isBalanceHidden = false,
),