diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/navigation/EditReturnTracker.kt b/features/send/api/src/main/java/com/tangem/features/send/api/navigation/EditReturnTracker.kt new file mode 100644 index 0000000000..98f2b93a63 --- /dev/null +++ b/features/send/api/src/main/java/com/tangem/features/send/api/navigation/EditReturnTracker.kt @@ -0,0 +1,27 @@ +package com.tangem.features.send.api.navigation + +/** + * Tracks the previously active route of a send-flow child stack to detect returns from edit screens. + * + * A Confirm screen receives the parent's state snapshot via its constructor only when it is freshly + + * Confirm instance is reused, so the flow component must re-push the parent's current state into it. + * The gate for that re-push must read the route that was active *before* the current one — the + * Confirm route itself always has `isEditMode = false` ([REDACTED_TASK_KEY]). + * + * @param isEditRoute returns whether the given route is an edit screen route + */ +class EditReturnTracker(private val isEditRoute: (R) -> Boolean) { + + private var previousRoute: R? = null + + /** + * Registers [route] as the currently active route and returns `true` if the route + * that was active before it was an edit route. + */ + fun onRouteActivated(route: R): Boolean { + val isReturnedFromEdit = previousRoute?.let(isEditRoute) == true + previousRoute = route + return isReturnedFromEdit + } +} \ No newline at end of file diff --git a/features/send/api/src/test/java/com/tangem/features/send/api/navigation/EditReturnTrackerTest.kt b/features/send/api/src/test/java/com/tangem/features/send/api/navigation/EditReturnTrackerTest.kt new file mode 100644 index 0000000000..820b0e2b1b --- /dev/null +++ b/features/send/api/src/test/java/com/tangem/features/send/api/navigation/EditReturnTrackerTest.kt @@ -0,0 +1,108 @@ +package com.tangem.features.send.api.navigation + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +internal class EditReturnTrackerTest { + + private sealed interface TestRoute { + val isEditMode: Boolean + + data class Amount(override val isEditMode: Boolean) : TestRoute + data class Destination(override val isEditMode: Boolean) : TestRoute + data object Confirm : TestRoute { + override val isEditMode: Boolean = false + } + data object Success : TestRoute { + override val isEditMode: Boolean = false + } + } + + private val tracker = EditReturnTracker { it.isEditMode } + + @Test + fun `GIVEN no previous route WHEN first route activated THEN no edit return detected`() { + val isReturnedFromEdit = tracker.onRouteActivated(TestRoute.Amount(isEditMode = false)) + + assertThat(isReturnedFromEdit).isFalse() + } + + @Test + fun `GIVEN linear flow WHEN confirm activated first time THEN no edit return detected`() { + // Arrange + tracker.onRouteActivated(TestRoute.Amount(isEditMode = false)) + tracker.onRouteActivated(TestRoute.Destination(isEditMode = false)) + + // Act + val isReturnedFromEdit = tracker.onRouteActivated(TestRoute.Confirm) + + // Assert + assertThat(isReturnedFromEdit).isFalse() + } + + @Test + fun `GIVEN amount edited from confirm WHEN popped back to confirm THEN edit return detected`() { + // Arrange: [REDACTED_TASK_KEY] reproduction — Amount -> Destination -> Confirm -> Amount(edit) -> Confirm + tracker.onRouteActivated(TestRoute.Amount(isEditMode = false)) + tracker.onRouteActivated(TestRoute.Destination(isEditMode = false)) + tracker.onRouteActivated(TestRoute.Confirm) + tracker.onRouteActivated(TestRoute.Amount(isEditMode = true)) + + // Act + val isReturnedFromEdit = tracker.onRouteActivated(TestRoute.Confirm) + + // Assert + assertThat(isReturnedFromEdit).isTrue() + } + + @Test + fun `GIVEN destination edited from confirm WHEN popped back to confirm THEN edit return detected`() { + // Arrange + tracker.onRouteActivated(TestRoute.Confirm) + tracker.onRouteActivated(TestRoute.Destination(isEditMode = true)) + + // Act + val isReturnedFromEdit = tracker.onRouteActivated(TestRoute.Confirm) + + // Assert + assertThat(isReturnedFromEdit).isTrue() + } + + @Test + fun `GIVEN edit return consumed WHEN next route activated THEN no edit return detected`() { + // Arrange + tracker.onRouteActivated(TestRoute.Confirm) + tracker.onRouteActivated(TestRoute.Amount(isEditMode = true)) + tracker.onRouteActivated(TestRoute.Confirm) + + // Act + val isReturnedFromEdit = tracker.onRouteActivated(TestRoute.Success) + + // Assert + assertThat(isReturnedFromEdit).isFalse() + } + + @Test + fun `GIVEN consecutive edits WHEN each pops back to confirm THEN each return detected independently`() { + tracker.onRouteActivated(TestRoute.Confirm) + + assertThat(tracker.onRouteActivated(TestRoute.Amount(isEditMode = true))).isFalse() + assertThat(tracker.onRouteActivated(TestRoute.Confirm)).isTrue() + assertThat(tracker.onRouteActivated(TestRoute.Destination(isEditMode = true))).isFalse() + assertThat(tracker.onRouteActivated(TestRoute.Confirm)).isTrue() + } + + @Test + fun `GIVEN confirm re-entered from non-edit route WHEN confirm activated THEN no edit return detected`() { + // Arrange: back from Confirm to Destination step, then Next re-pushes Confirm + tracker.onRouteActivated(TestRoute.Destination(isEditMode = false)) + tracker.onRouteActivated(TestRoute.Confirm) + tracker.onRouteActivated(TestRoute.Destination(isEditMode = false)) + + // Act + val isReturnedFromEdit = tracker.onRouteActivated(TestRoute.Confirm) + + // Assert + assertThat(isReturnedFromEdit).isFalse() + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt index b4fe92d902..ba7b0fe261 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt @@ -22,6 +22,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.models.account.derivationIndex import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.navigation.EditReturnTracker import com.tangem.features.send.api.subcomponents.amount.AmountRoute import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams @@ -61,6 +62,8 @@ internal class DefaultSendComponent @AssistedInject constructor( private val model: SendModel = getOrCreateModel(params = params, router = innerRouter) + private val editReturnTracker = EditReturnTracker { it.isEditMode } + private val childStack = childStack( key = "sendInnerStack", source = stackNavigation, @@ -83,6 +86,7 @@ internal class DefaultSendComponent @AssistedInject constructor( lifecycle = lifecycle, mode = ObserveLifecycleMode.CREATE_DESTROY, ) { stack -> + val isReturnedFromEdit = editReturnTracker.onRouteActivated(stack.active.configuration) when (val activeComponent = stack.active.instance) { is SendConfirmComponent -> { val fromCurrency = params.currency @@ -99,8 +103,9 @@ internal class DefaultSendComponent @AssistedInject constructor( type = model.consumeEntryType(), ), ) - if (childStack.value.active.configuration.isEditMode) { - activeComponent.updateState(model.uiState.value) + // A reused Confirm gets no constructor state — re-push the edited fields on edit-return + if (isReturnedFromEdit) { + activeComponent.updateEditedState(model.uiState.value) } } is SendAmountComponent -> { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt index d13114d87c..80bd17ddcd 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt @@ -137,11 +137,10 @@ internal class SendConfirmComponent( }.launchIn(componentScope) } - fun updateState(state: SendUM) { + fun updateEditedState(state: SendUM) { destinationBlockComponent.updateState(state.destinationUM) amountBlockComponent.updateState(state.amountUM) - feeSelectorBlockComponent.updateState(state.feeSelectorUM) - model.updateState(state) + model.updateEditedState(state) } @Composable diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt index 61f56380eb..b1f2ec0625 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt @@ -166,8 +166,13 @@ internal class SendConfirmModel @Inject constructor( subscribeOnTapHelpUpdates() } - fun updateState(state: SendUM) { - _uiState.value = state + /** + * Applies the fields editable outside Confirm (amount, destination) from the parent's [state]. + * Confirm-local fields (confirmUM, feeSelectorUM) must be kept — the parent's copies of them + * stay stale until a successful send. + */ + fun updateEditedState(state: SendUM) { + _uiState.update { it.copy(amountUM = state.amountUM, destinationUM = state.destinationUM) } onFeeReload() updateConfirmNotifications() } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt index 05b948fa64..1ee1ec5f49 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt @@ -19,6 +19,7 @@ import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.send.api.NFTSendComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.navigation.EditReturnTracker import com.tangem.features.send.api.subcomponents.destination.DestinationRoute import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams @@ -53,6 +54,8 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( private val model: NFTSendModel = getOrCreateModel(params = params, router = innerRouter) + private val editReturnTracker = EditReturnTracker { it.isEditMode } + private val childStack = childStack( key = "NFTSendInnerStack", source = stackNavigation, @@ -75,6 +78,7 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( lifecycle = lifecycle, mode = ObserveLifecycleMode.CREATE_DESTROY, ) { stack -> + val isReturnedFromEdit = editReturnTracker.onRouteActivated(stack.active.configuration) when (val activeComponent = stack.active.instance) { is NFTSendConfirmComponent -> { val fromCurrency = model.cryptoCurrency @@ -90,9 +94,9 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( toDerivationIndex = null, ), ) - // Push current state into a reused Confirm on (re)entry. Confirm.isEditMode is `true` - if (stack.active.configuration.isEditMode) { - activeComponent.updateState(model.uiState.value) + // A reused Confirm gets no constructor state — re-push the edited fields on edit-return + if (isReturnedFromEdit) { + activeComponent.updateEditedState(model.uiState.value) } } is SendDestinationComponent -> { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt index 297793f4e6..8fa80253cb 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt @@ -134,9 +134,9 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( }.launchIn(componentScope) } - fun updateState(state: NFTSendUM) { + fun updateEditedState(state: NFTSendUM) { destinationBlockComponent.updateState(state.destinationUM) - model.updateState(state) + model.updateEditedState(state) } @Composable diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt index 98216a2934..00e660c583 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt @@ -127,8 +127,13 @@ internal class NFTSendConfirmModel @Inject constructor( initialState() } - fun updateState(nftSendUM: NFTSendUM) { - _uiState.value = nftSendUM + /** + * Applies the field editable outside Confirm (destination) from the parent's [nftSendUM]. + * Confirm-local fields (confirmUM, feeSelectorUM) must be kept — the parent's copies of them + * stay stale until a successful send. + */ + fun updateEditedState(nftSendUM: NFTSendUM) { + _uiState.update { it.copy(destinationUM = nftSendUM.destinationUM) } updateConfirmNotifications() } diff --git a/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/SendConfirmModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/SendConfirmModelTest.kt index 370ac09ac5..059139d32b 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/SendConfirmModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/SendConfirmModelTest.kt @@ -6,6 +6,7 @@ import arrow.core.right import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee +import com.google.common.truth.Truth.assertThat import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -235,6 +236,59 @@ internal class SendConfirmModelTest : SendModelTestBase() { } } + @Nested + inner class UpdateEditedState { + + @Test + fun `GIVEN stale parent state WHEN updateEditedState THEN amount and destination applied`() = runTest { + // Arrange + val sut = createSendConfirmModel(this, confirmParams(normalFeeState())) + advanceUntilIdle() + val editedAmount = mockk(relaxed = true) + val editedDestination = mockk(relaxed = true) + + // Act + sut.updateEditedState(staleParentState(editedAmount, editedDestination)) + advanceUntilIdle() + + // Assert + assertThat(sut.uiState.value.amountUM).isEqualTo(editedAmount) + assertThat(sut.uiState.value.destinationUM).isEqualTo(editedDestination) + coVerify(exactly = 1) { feeSelectorReloadTrigger.triggerUpdate() } + } + + @Test + fun `GIVEN stale parent state WHEN updateEditedState THEN confirm-local state preserved`() = runTest { + // Arrange: the parent's confirmUM/feeSelectorUM stay Empty/Loading until a successful send — + // they must not leak into the confirm model (blocks turn unclickable on ConfirmUM.Empty) + val sut = createSendConfirmModel(this, confirmParams(normalFeeState())) + advanceUntilIdle() + val feeSelectorUMBefore = sut.uiState.value.feeSelectorUM + + // Act + sut.updateEditedState( + staleParentState( + amountUM = mockk(relaxed = true), + destinationUM = mockk(relaxed = true), + ), + ) + advanceUntilIdle() + + // Assert: confirmUM may be recomputed (notifications), but must stay Content — never the + // parent's Empty, which would disable the confirm blocks; the fee state must survive as is + assertThat(sut.uiState.value.confirmUM).isInstanceOf(ConfirmUM.Content::class.java) + assertThat(sut.uiState.value.feeSelectorUM).isEqualTo(feeSelectorUMBefore) + } + + private fun staleParentState(amountUM: AmountState, destinationUM: DestinationUM) = SendUM( + amountUM = amountUM, + destinationUM = destinationUM, + feeSelectorUM = FeeSelectorUM.Loading, + confirmUM = ConfirmUM.Empty, + confirmData = null, + ) + } + // region fixtures private fun confirmParams(state: SendUM) = MutableParamsContainer( diff --git a/features/send/impl/src/test/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModelTest.kt index e6e57ae776..cec38c9540 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModelTest.kt @@ -3,6 +3,7 @@ package com.tangem.features.send.sendnft.confirm.model import android.os.SystemClock import arrow.core.left import arrow.core.right +import com.google.common.truth.Truth.assertThat import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee @@ -261,6 +262,36 @@ internal class NFTSendConfirmModelTest { } } + @Nested + inner class UpdateEditedState { + + @Test + fun `GIVEN stale parent state WHEN updateEditedState THEN destination applied and local state kept`() = + runTest { + // Arrange + val sut = buildModel() + advanceUntilIdle() + val feeSelectorUMBefore = sut.uiState.value.feeSelectorUM + val editedDestination = mockk(relaxed = true) + + // Act: the parent's confirmUM/feeSelectorUM stay Empty/Loading until a successful send — + // they must not leak into the confirm model (blocks turn unclickable on ConfirmUM.Empty) + sut.updateEditedState( + NFTSendUM( + destinationUM = editedDestination, + feeSelectorUM = FeeSelectorUM.Loading, + confirmUM = ConfirmUM.Empty, + ), + ) + advanceUntilIdle() + + // Assert + assertThat(sut.uiState.value.destinationUM).isEqualTo(editedDestination) + assertThat(sut.uiState.value.confirmUM).isInstanceOf(ConfirmUM.Content::class.java) + assertThat(sut.uiState.value.feeSelectorUM).isEqualTo(feeSelectorUMBefore) + } + } + // region fixtures private fun TestScope.buildModel( diff --git a/features/swap-v2/CLAUDE.md b/features/swap-v2/CLAUDE.md index 0f80e7d910..6d401cfe1c 100644 --- a/features/swap-v2/CLAUDE.md +++ b/features/swap-v2/CLAUDE.md @@ -61,33 +61,37 @@ Consistent `.v2` segment (unlike the legacy `features/swap` which uses `feature. (`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) +- `onResult(sendWithSwapUM)` → `uiState.value = …` (full replace; 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: +`DefaultSendWithSwapComponent.init { childStack.subscribe(CREATE_DESTROY) { stack → … } }`: +on every active-child change it **pushes the parent's current snapshot into the newly-active child**: ```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! +val isReturnedFromEdit = editReturnTracker.onRouteActivated(stack.active.configuration) // synchronous! +componentScope.launch { + when (active) { + is SwapAmountComponent -> active.updateState(uiState.value.amountUM) + is SendDestinationComponent -> active.updateState(uiState.value.destinationUM) // screen + is SendWithSwapConfirmComponent -> { + if (isReturnedFromEdit) active.updateEditedState(uiState.value) // ← gated! + active.updateDestinationState(uiState.value.destinationUM) // [REDACTED_TASK_KEY] bypass + } + } } -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. +The gate must be true exactly when returning to a *reused* Confirm from an edit step — the Confirm route +itself has `isEditMode = false`, so the check reads the **previous** route via `EditReturnTracker` +(`features/send/api/.../navigation/EditReturnTracker.kt`, shared with Send and NFT Send). Gating on the +*new* active configuration instead made the re-push unreachable and Confirm kept stale amounts ([REDACTED_TASK_KEY]). +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: @@ -102,9 +106,12 @@ re-created fresh from `params.sendWithSwapUM`, so no re-push is needed. `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). +- `updateEditedState(sendWithSwapUM)` — the edit-return re-push; copies ONLY the parent-owned fields + (`amountUM`, `destinationUM`). Confirm-local `confirmUM`/`feeSelectorUM` must never be overwritten + from the parent (a full replace kills `blockClickEnableFlow` — parent's `confirmUM` is `Empty`). +- Publishes its state up to the parent only **after a successful send** (`callback.onResult(uiState.value)`). + Until then, confirm-local changes (fee selection, confirmUM) live only in this model — the parent's + copy is stale. - Sending: `SwapTransactionSender` (CEX only; DEX/DEX_BRIDGE/ONRAMP rejected). Success → `SendWithSwapConfirmSentStateTransformer` + `router.replaceAll(Success)`. @@ -184,11 +191,11 @@ auto-next, and **on back only when `!route.isEditMode`**. `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). +- **The Confirm re-push gate reads the *previous* route.** The reused-Confirm `updateState` in the + childStack subscription is gated on `EditReturnTracker.onRouteActivated(...)` (send api), which reports + whether the route active *before* the new one was an edit route. Gating on the new active + configuration (`Confirm.isEditMode` — always `false`) silently disables the re-push ([REDACTED_TASK_KEY]). + The tracker must be called synchronously in the subscribe callback, not inside a launched coroutine. - **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. 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 edf0958a75..533eb6fb2e 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 @@ -20,6 +20,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.swap.models.R import com.tangem.domain.swap.models.SwapDirection import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.navigation.EditReturnTracker import com.tangem.features.send.api.subcomponents.destination.DestinationRoute import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams @@ -57,6 +58,8 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( private val model: SendWithSwapModel = getOrCreateModel(params = params, router = innerRouter) + private val editReturnTracker = EditReturnTracker { it.isEditMode } + private val childStack = childStack( key = "sendWithSwapInnerStack", source = stackNavigation, @@ -79,6 +82,8 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( lifecycle = lifecycle, mode = ObserveLifecycleMode.CREATE_DESTROY, ) { stack -> + // Read synchronously: coroutine scheduling must not reorder tracker updates between stack events + val isReturnedFromEdit = editReturnTracker.onRouteActivated(stack.active.configuration) componentScope.launch { when (val activeComponent = stack.active.instance) { is SwapAmountComponent -> { @@ -94,8 +99,9 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( activeComponent.updateState(model.uiState.value.destinationUM) } is SendWithSwapConfirmComponent -> { - if (stack.active.configuration.isEditMode) { - activeComponent.updateState(model.uiState.value) + // A reused Confirm gets no constructor state — re-push the edited fields on edit-return + if (isReturnedFromEdit) { + activeComponent.updateEditedState(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) 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 1a601cbc7d..118678eee8 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 @@ -179,10 +179,9 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( .launchIn(componentScope) } - fun updateState(sendWithSwapUM: SendWithSwapUM) { + fun updateEditedState(sendWithSwapUM: SendWithSwapUM) { amountBlockComponent.updateState(sendWithSwapUM.amountUM) - feeSelectorBlockComponent.updateState(sendWithSwapUM.feeSelectorUM) - model.updateState(sendWithSwapUM) + model.updateEditedState(sendWithSwapUM) } // Re-sync destination from parent on Confirm entry, bypassing the edit-mode gate; Empty only occurs on diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 31c16b0228..3729aca809 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -196,8 +196,19 @@ internal class SendWithSwapConfirmModel @Inject constructor( updateConfirmNotifications() } - fun updateState(sendWithSwapUM: SendWithSwapUM) { - uiState.value = sendWithSwapUM + /** + * Applies the fields editable outside Confirm (amount, destination) from the parent's [sendWithSwapUM]. + * Confirm-local fields (confirmUM, feeSelectorUM) must be kept — the parent's copies of them + * stay stale until a successful send. + */ + fun updateEditedState(sendWithSwapUM: SendWithSwapUM) { + uiState.update { state -> + state.copy( + amountUM = sendWithSwapUM.amountUM, + destinationUM = sendWithSwapUM.destinationUM, + ) + } + updateConfirmNotifications() } override fun onFeeReload() {