Updated on 2026-08-14
This commit is contained in:
parent
4383291669
commit
fec7986d80
16 changed files with 1322 additions and 587 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue