From fec7986d804d71714bae915cf4437b3833a440ef Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Jun 2026 17:10:30 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../AmountVisualTransformation.kt | 36 +- .../AmountVisualTransformationTest.kt | 84 ++++ features/swap/CLAUDE.md | 409 ++++++------------ .../tangem/feature/swap/model/SwapModel.kt | 156 ++++++- .../feature/swap/models/SwapStateHolder.kt | 11 +- .../tangem/feature/swap/models/UiActions.kt | 1 + .../feature/swap/ui/AutosizeTextField.kt | 108 ----- .../tangem/feature/swap/ui/StateBuilder.kt | 195 +++++++-- .../swap/ui/SwapAmountScreenClickIntents.kt | 27 ++ .../tangem/feature/swap/ui/TransactionCard.kt | 249 +++++++---- .../feature/swap/ui/TransactionCardSimple.kt | 27 +- .../ui/preview/SwapTransactionCardPreview.kt | 64 ++- .../ui/transfer/SwapTransferStateBuilder.kt | 103 ++++- .../swap/StateBuilderUpdateSwapAmountTest.kt | 302 +++++++++++++ .../ui/SwapAmountScreenClickIntentsTest.kt | 82 ++++ .../transfer/SwapTransferStateBuilderTest.kt | 55 ++- 16 files changed, 1322 insertions(+), 587 deletions(-) create mode 100644 core/ui/src/test/kotlin/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformationTest.kt delete mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/ui/AutosizeTextField.kt create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapAmountScreenClickIntents.kt create mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderUpdateSwapAmountTest.kt create mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/ui/SwapAmountScreenClickIntentsTest.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt index 3349f73c68..ffdc93fd25 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt @@ -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 { diff --git a/core/ui/src/test/kotlin/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformationTest.kt b/core/ui/src/test/kotlin/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformationTest.kt new file mode 100644 index 0000000000..2da1b06fd0 --- /dev/null +++ b/core/ui/src/test/kotlin/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformationTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/features/swap/CLAUDE.md b/features/swap/CLAUDE.md index dfb670e330..bec99d0844 100644 --- a/features/swap/CLAUDE.md +++ b/features/swap/CLAUDE.md @@ -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`; 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` — approval bottom sheet (`GiveApprovalComponent`) -- `SlotNavigation` — 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` — 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` — stack navigation exposed from `SwapRouter` -- `approvalSlotNavigation: SlotNavigation` — 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`, `selectedProvider`, `lastLoadedSwapStates: Map`, `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`. `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>` -- `findProvidersForPair(from, to, pairs)` → `List` -- `findProvidersForPairWithCheck(from, to, pairs)` → `List` (checks asset requirements/FCA) -- `findBestQuote(from, to, providers, amount, reduceBalanceBy)` → `Map` (parallel per-provider) -- `onSwap(from, to, provider, swapData, amount, includeFeeInAmount, fee, operationType, isTangemPayWithdrawal)` → `SwapTransactionState` -- `loadSwapFee(provider, fromStatus, toStatus, amount, swapData, selectedFeeToken)` → `Either` — 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 → GiveApprovalComponent (bottom sheet) + └─ SlotNavigation → 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` -- `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 (Approval) - │ └─ GiveApprovalComponent (bottom sheet) - └─ SlotNavigation - └─ 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. \ No newline at end of file +**Transfer mode vs swap mode.** `shouldTransferInsteadOfSwap` returns `true` for same-wallet +same-currency pairs → UI shows `SwapState.Transfer`, not `QuotesLoadedState`, and no fee selector. \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 92d6de4307..460b766115 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -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>() @@ -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 diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index 24b806a74b..6facac10e1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -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( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt index 67c95faf22..97fe358356 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt @@ -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, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/AutosizeTextField.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/AutosizeTextField.kt deleted file mode 100644 index 8e2e551bd2..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/AutosizeTextField.kt +++ /dev/null @@ -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), - ) - } - } -} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index ee43b3078f..64e53e0692 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -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, ), ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapAmountScreenClickIntents.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapAmountScreenClickIntents.kt new file mode 100644 index 0000000000..d45198cdcd --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapAmountScreenClickIntents.kt @@ -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 +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index c4c9307346..507f7a5183 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -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) { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCardSimple.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCardSimple.kt index a807f078a3..085404f678 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCardSimple.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCardSimple.kt @@ -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() diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt index c64731f361..d57d2b84eb 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt @@ -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)), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt index 992242fb67..28ab6ffc5e 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt @@ -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 diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderUpdateSwapAmountTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderUpdateSwapAmountTest.kt new file mode 100644 index 0000000000..23d7079e8a --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderUpdateSwapAmountTest.kt @@ -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 = mockk() + private val appCurrencyProvider: Provider = mockk() + private val isAccountsModeProvider: Provider = 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 + } +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/SwapAmountScreenClickIntentsTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/SwapAmountScreenClickIntentsTest.kt new file mode 100644 index 0000000000..90da2f7079 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/SwapAmountScreenClickIntentsTest.kt @@ -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() + } +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt index 6b2e8b161e..e19b4ec0ec 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt @@ -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, ),