From fb69f0df7cb86286242c6081565f8106a1578a96 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Jun 2026 19:51:45 +0400 Subject: [PATCH] Updated on 2026-08-14 --- features/swap-v2/CLAUDE.md | 202 ++++++++++++++++++ .../DefaultSendWithSwapComponent.kt | 2 + .../confirm/SendWithSwapConfirmComponent.kt | 24 ++- 3 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 features/swap-v2/CLAUDE.md diff --git a/features/swap-v2/CLAUDE.md b/features/swap-v2/CLAUDE.md new file mode 100644 index 0000000000..0f80e7d910 --- /dev/null +++ b/features/swap-v2/CLAUDE.md @@ -0,0 +1,202 @@ +# Swap V2 / Send-with-Swap Feature + +This module implements **Send-with-Swap (SvS)**: a send transaction where the sent token is swapped +(CEX) to a different *receive* token at a *destination address* in one flow. The user picks a receive +token, enters amounts (with Fixed/Float rate), enters a destination address (+ memo for memo-networks), +reviews on Confirm, and sends. + +> There is **no standalone token↔token swap UI** in this module — that lives in `features/swap/` +> (see `features/swap/CLAUDE.md`). swap-v2 is the redesigned **send-with-swap** flow plus its shared +> amount/provider/notifications subscreens, built on the **send-v2** subcomponents. + +## Module Structure + +``` +features/swap-v2/ + api/ — com.tangem.features.swap.v2.api + SendWithSwapComponent (+ Params/Factory), SwapFeatureToggles, + SwapAmountUpdateTrigger, subcomponents/, choosetoken/ + impl/ — com.tangem.features.swap.v2.impl (android-library + Hilt/kapt) + sendviaswap/ — SvS flow root, model, routes, confirm/, success/, analytics/ + amount/ — swap amount screen (model, transformers, converters, entity, ui) + chooseprovider/— provider selector bottom sheet + choosetoken/ — receive-token / network selection + notifications/ — swap-specific notifications (price impact, express errors) + common/ — ConfirmData, SwapAlertFactory, SwapUtils, entities (ConfirmUM, SwapQuoteUM) + di/ — Hilt modules +``` + +**Package naming:** API = `com.tangem.features.swap.v2.api`, Impl = `com.tangem.features.swap.v2.impl`. +Consistent `.v2` segment (unlike the legacy `features/swap` which uses `feature.swap` for impl). + +**Build commands:** +```bash +./gradlew :features:swap-v2:impl:compileDebugKotlin +./gradlew :features:swap-v2:api:compileDebugKotlin +./gradlew :features:swap-v2:impl:testDebugUnitTest +./gradlew :features:swap-v2:impl:detekt +``` + +## The SvS Flow (sendviaswap/) + +### Entry: SendWithSwapComponent (api) / DefaultSendWithSwapComponent (impl) +- `SendWithSwapComponent.Params`: `userWalletId`, `currency` (the **FROM** token), `callback`. +- `DefaultSendWithSwapComponent` (`impl/.../sendviaswap/DefaultSendWithSwapComponent.kt`) owns an inner + `StackNavigation` + `InnerRouter`, creates `SendWithSwapModel` via + `getOrCreateModel`, and a `childStack` rendering Amount/Destination/Confirm/Success. + +### Routes: SendWithSwapRoute +`impl/.../sendviaswap/SendWithSwapRoute.kt` — sealed `Route`, every entry has `isEditMode: Boolean`: +- `Amount(isEditMode)` — implements `SwapAmountRoute` +- `Destination(isEditMode)` — implements send-v2 `DestinationRoute` +- `Confirm` (object, `isEditMode = false`) +- `Success` (object, `isEditMode = false`) + +`isEditMode` distinguishes the **linear** forward flow (`Amount → Destination → Confirm`) from +**re-editing** a step *from Confirm* (`showEditAmount`/`showEditDestination` push the step with +`isEditMode = true`; `onNextClick` then **pops** back to Confirm instead of advancing). + +### Parent model: SendWithSwapModel +`impl/.../sendviaswap/model/SendWithSwapModel.kt`. `@ModelScoped`. Implements three child callbacks +(`SwapAmountComponent.ModelCallback`, `SendDestinationComponent.ModelCallback`, +`SendWithSwapConfirmComponent.ModelCallback`). Holds the **aggregate** state: +- `uiState: StateFlow` — `{ amountUM, destinationUM, feeSelectorUM, confirmUM, navigationUM }` +- `currentRoute: MutableStateFlow` +- `primaryCryptoCurrencyStatusFlow`, `primaryFeePaidCurrencyStatusFlow`, `accountFlow`, + `isAccountModeFlow`, `isBalanceHiddenFlow` — read-only sources passed down to children as params. + +Child→parent merge callbacks: +- `onAmountResult(amountUM)` → `uiState.copy(amountUM = …)` +- `onDestinationResult(destinationUM)` → `uiState.copy(destinationUM = …)` +- `onResult(route, sendWithSwapUM)` → **`if (currentRoute.value == route) uiState.value = …`** (full replace, + route-guarded; used by Confirm to publish its full state back up) +- `onNavigationResult(navigationUM)` → drives the shared footer button/app-bar. + +### childStack subscription = the state-sync mechanism (READ THIS) +`DefaultSendWithSwapComponent.init { childStack.subscribe(CREATE_DESTROY) { stack → componentScope.launch { … } } }`: +on every active-child change it **pushes the parent's current snapshot into the newly-active child** and +then emits the new route: +```kotlin +when (active) { + is SwapAmountComponent -> active.updateState(uiState.value.amountUM) + is SendDestinationComponent -> active.updateState(uiState.value.destinationUM) // screen + is SendWithSwapConfirmComponent -> + if (model.currentRoute.value.isEditMode) active.updateState(uiState.value) // ← gated! +} +model.currentRoute.emit(stack.active.configuration) // emitted AFTER the isEditMode read +``` +The `isEditMode` check intentionally reads the **previous** route (the emit happens afterwards) so it is +true exactly when returning to a *reused* Confirm from an edit step. In the linear flow Confirm is +re-created fresh from `params.sendWithSwapUM`, so no re-push is needed. + +### Confirm: SendWithSwapConfirmComponent / SendWithSwapConfirmModel +`impl/.../sendviaswap/confirm/`. The Confirm screen embeds **read-only blocks** reused from send-v2: +- `SwapAmountBlockComponent` (swap-v2) +- `SendDestinationBlockComponent` (send-v2) — shows address + memo, click → `showEditDestination` +- `FeeSelectorBlockComponent` (send-v2) +- `SendNotificationsComponent` (send-v2) + `SwapNotificationsComponent` (swap-v2) + +`SendWithSwapConfirmModel`: +- `uiState: StateFlow` seeded from `params.sendWithSwapUM`. +- `confirmData: ConfirmData` (computed) — extracts `enteredFromAmount/enteredToAmount`, + `enteredDestination`, `enteredMemo`, `fee`, statuses, quote, rateType, amountType, priceImpact from + `uiState`; this is what the transaction + notifications are built from. +- `onFeeResult/onAmountResult/onDestinationResult` — block callbacks copy into `uiState`. +- `updateState(sendWithSwapUM)` — full replace (used by the edit-mode re-push). +- `configConfirmNavigation` — `combine(uiState, currentRoute).filter { route is Confirm }` → + `callback.onResult(Confirm, state.copy(navigationUM = …))` (publishes confirm state up to the parent). +- Sending: `SwapTransactionSender` (CEX only; DEX/DEX_BRIDGE/ONRAMP rejected). Success → + `SendWithSwapConfirmSentStateTransformer` + `router.replaceAll(Success)`. + +### Success: SendWithSwapSuccessComponent +`impl/.../sendviaswap/success/` — renders `ConfirmUM.Success` (tx date, explorer url, provider, swap data). + +## Amount screen (amount/) + +- `SwapAmountComponent` / `SwapAmountModel` (`amount/model/SwapAmountModel.kt`, ~big orchestrator). +- State `SwapAmountUM` (`amount/entity/SwapAmountUM.kt`): `Empty(swapDirection)` | `Content` with + `primaryAmount`/`secondaryAmount` fields, `primary/secondaryCryptoCurrencyStatus`, + `swapRateType: ExpressRateType` (Fixed|Float), `swapQuotes`, `selectedQuote: SwapQuoteUM`, `priceImpact`. +- Quotes are loaded periodically via a task scheduler and through `GetSwapQuoteUseCase`. +- Transformers (`amount/model/transformers/`): `SwapAmountValueChangeTransformer`, + `SwapAmountSelectQuoteTransformer`, `SwapAmountSetQuotesTransformer`, + `SwapAmountChangeAmountTypeTransformer`, `SwapAmount{Reduce*,Max,Paste,…}Transformer`, applied via + `uiState.transformerUpdate(…)`. +- **Fixed vs Float:** `SwapAmountType.To` must use `ExpressRateType.Fixed` (the float API can't target a + to-amount); `SwapAmountType.From` uses `Float`. Provider filtering checks + `provider.rateTypes.contains(rateType)` before requesting a quote. + +## Choose provider / token, Notifications + +- `chooseprovider/` — `SwapChooseProviderComponent`/`Model`, bottom-sheet provider list (converters + `SwapProviderListItemConverter`, `SwapProviderStateConverter`). +- `choosetoken/` — receive-token + network selection (`SwapChooseTokenNetworkModel`, transformers). +- `notifications/` — `SwapNotificationsComponent`/`Model`, driven by `SwapNotificationsUpdateTrigger`/ + `…Listener`; produces price-impact / express-error / destination-tag-required notifications. + +## Reused send-v2 subcomponents (API boundary) + +SvS consumes these `features/send-v2/api` contracts (impl injected via DI): +- `SendDestinationComponent.Factory` — the navigable **address/memo screen**. +- `SendDestinationBlockComponent.Factory` — the **read-only block** on Confirm. +- `FeeSelectorBlockComponent.Factory` + `FeeSelectorReloadTrigger`. +- `SendNotificationsComponent.Factory` + `SendNotificationsUpdateTrigger`/`…Listener`. +- Entities: `DestinationUM`, `FeeSelectorUM`, `NavigationUM`, `PredefinedValues`. + +The shared destination model is **`features/send-v2/.../subcomponents/destination/model/SendDestinationModel.kt`**. +Its `updateState(destinationUM)` does `if (Content && isInitialized) _uiState.value = destinationUM` +(StateFlow dedups equal values). `saveResult()` (push to the parent callback) runs on Next, on +auto-next, and **on back only when `!route.isEditMode`**. + +## DI modules (di/ and per-subpackage di/) + +| Module | Scope | Provides | +|---|---|---| +| `SwapFeatureModules` | Singleton | `SwapFeatureToggles` | +| `SendWithSwapModule` | Singleton + Model | `SendWithSwapComponent.Factory`, `SendWithSwapModel` | +| `SwapAmountModule` | Singleton + Model | `SwapAmountModel`, `SwapAmountUpdateTrigger/Listener`, `SwapAmountReduceTrigger/Listener` | +| `SendWithSwapConfirmModule` | Model | `SendWithSwapConfirmModel` | +| `SwapChooseProviderModule` | Model | `SwapChooseProviderModel` | +| `SwapChooseTokenModule` | Singleton + Model | choose-token factories/model | +| `SwapNotificationsModule` | Singleton + Model | `SwapNotificationsModel`, `SwapNotificationsUpdateTrigger/Listener` | + +## Analytics + +- `SendWithSwapAnalyticEvents` (`sendviaswap/analytics/`) — `ConfirmationScreenOpened`, + `AmountScreenOpened`, `TransactionScreenOpened`, `OnSendClick`, `NoticeFixedRate/FloatRate`, + `Error{InsufficientBalance,MinAmount,MaxAmount,ExpressQuote}`, `HighPriceImpact`, `TradeTooLarge`; + category = `CommonSendAnalyticEvents.SEND_CATEGORY`. `ExpressRateType.toAnalyticsRateType()` maps rate. +- `SwapAmountAnalyticEvents` + `SwapAmountAnalyticsSender` (`amount/analytics/`) — provider selector events. + +## State-management patterns & gotchas + +- **Transformer pattern:** `uiState.transformerUpdate(SomeTransformer(...))`; transformers early-return + `prevState` if not the expected subtype (`as? Content ?: return prevState`). +- **Three+ StateFlows hold the destination at once.** The memo/address lives in: the navigable + Destination **screen** model (#A), the parent `SendWithSwapModel.uiState.destinationUM` (#B), the + `SendWithSwapConfirmModel.uiState.destinationUM` (#C), and the Confirm-embedded destination **block** + model (#D, what Confirm actually displays). They are synced by **snapshot copies** (`updateState`, + `onResult`, `onDestinationResult`) over `StateFlow.value =` (which **dedups by `equals`**), plus the + block's self-feeding `init { uiState.onEach { onResult(it) } }`. This is fragile — see [REDACTED_TASK_KEY] + ("floating memo": an edit on #A intermittently fails to reach #D). Prefer a single source of truth + when touching this area; do **not** assume an `updateState` re-push actually emits (equal value = no-op). +- **Edit-mode back does not persist.** Leaving an edit step via the back arrow / system back skips + `saveResult()` (`SendDestinationModel.configDestinationNavigation`, `if (!route.isEditMode)`), so the + parent keeps the pre-edit value. The footer "Continue"/"Next" button always persists. This is shared + by regular Send + NFT Send + SvS. +- **`onResult` is route-guarded.** `SendWithSwapModel.onResult` only applies when + `currentRoute.value == route`, which protects against late/stale Confirm emissions overwriting the + parent after navigating away. Keep that guard if you refactor. +- **`currentRoute.emit` runs at the END of the subscribe coroutine**, so the `isEditMode` re-push gate + reads the *previous* route. Relies on `componentScope` launches being serialized (main dispatcher). +- **CEX-only.** `SwapTransactionSender` rejects DEX/DEX_BRIDGE/ONRAMP. Destination address for CEX is + only known after exchange-data, so confirm notifications pass `destinationAddress = null` for the + send-notifications path. + +## Testing + +JUnit 5 + MockK + Turbine + Truth (see project `.claude/rules/unit-testing.md`). Feature-model tests +build the heavy graph with relaxed mocks and a single `StandardTestDispatcher`; drive with +`advanceUntilIdle()` and `model.onDestroy()`. For SvS state-sync regressions, prefer parent-model +(`SendWithSwapModel`) tests asserting that an edit propagated through `onDestinationResult` is the value +that `uiState.destinationUM` ends up holding across an edit→confirm round trip. \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt index d6e438be46..f34b1ad38e 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt @@ -102,6 +102,8 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( if (model.currentRoute.value.isEditMode) { activeComponent.updateState(model.uiState.value) } + // Re-sync destination from parent on Confirm entry, bypassing the edit-mode gate ([REDACTED_TASK_KEY]). + activeComponent.updateDestinationState(model.uiState.value.destinationUM) val fromCurrency = params.currency val content = model.uiState.value.amountUM as? SwapAmountUM.Content ?: return@launch val toCurrency = content.secondaryCryptoCurrencyStatus?.currency ?: return@launch diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt index 467a6f134b..424c5d9d76 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt @@ -21,6 +21,7 @@ import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.params.FeeSelectorParams.* import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.swap.v2.impl.amount.SwapAmountBlockComponent import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams import com.tangem.features.swap.v2.impl.common.SwapUtils.SEND_WITH_SWAP_PROVIDER_TYPES @@ -40,7 +41,7 @@ import kotlinx.coroutines.flow.* internal class SendWithSwapConfirmComponent @AssistedInject constructor( @Assisted private val appComponentContext: AppComponentContext, @Assisted private val params: Params, - sendDestinationBlockComponent: SendDestinationBlockComponent.Factory, + sendDestinationBlockComponentFactory: SendDestinationBlockComponent.Factory, feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory, sendNotificationsComponentFactory: SendNotificationsComponent.Factory, ) : ComposableContentComponent, AppComponentContext by appComponentContext { @@ -69,7 +70,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( onClick = model::showEditAmount, ) - private val sendDestinationBlockComponent = sendDestinationBlockComponent.create( + private val sendDestinationBlockComponent = sendDestinationBlockComponentFactory.create( context = child("sendWithSwapConfirmDestinationBlock"), params = SendDestinationComponentParams.DestinationBlockParams( state = model.uiState.value.destinationUM, @@ -81,7 +82,8 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( predefinedValues = PredefinedValues.Empty, isAllowSelfSend = true, ), - onResult = model::onDestinationResult, + // No feedback: the read-only block is driven one-way by the model.uiState collector ([REDACTED_TASK_KEY]). + onResult = {}, onClick = model::showEditDestination, ) @@ -151,15 +153,29 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( val confirmUM = state.confirmUM as? ConfirmUM.Content blockClickEnableFlow.value = confirmUM?.isTransactionInProcess == false }.launchIn(componentScope) + + // Single source of truth: the block always mirrors the model's authoritative destinationUM. + model.uiState + .map { it.destinationUM } + .distinctUntilChanged() + .onEach(sendDestinationBlockComponent::updateState) + .launchIn(componentScope) } fun updateState(sendWithSwapUM: SendWithSwapUM) { amountBlockComponent.updateState(sendWithSwapUM.amountUM) - sendDestinationBlockComponent.updateState(sendWithSwapUM.destinationUM) feeSelectorBlockComponent.updateState(sendWithSwapUM.feeSelectorUM) model.updateState(sendWithSwapUM) } + // Re-sync destination from parent on Confirm entry, bypassing the edit-mode gate; Empty only occurs on + // reset (which leaves Confirm), so only Content is applied ([REDACTED_TASK_KEY]). + fun updateDestinationState(destinationUM: DestinationUM) { + if (destinationUM is DestinationUM.Content && destinationUM != model.uiState.value.destinationUM) { + model.onDestinationResult(destinationUM) + } + } + @Composable override fun Content(modifier: Modifier) { val sendWithSwapUM by model.uiState.collectAsStateWithLifecycle()