Updated on 2026-08-14
This commit is contained in:
commit
05b27a338b
14 changed files with 305 additions and 44 deletions
|
|
@ -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<R : Any>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<TestRoute> { 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ import com.tangem.core.ui.extensions.resourceReference
|
||||||
import com.tangem.domain.models.account.derivationIndex
|
import com.tangem.domain.models.account.derivationIndex
|
||||||
import com.tangem.features.send.api.SendComponent
|
import com.tangem.features.send.api.SendComponent
|
||||||
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
|
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.AmountRoute
|
||||||
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent
|
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent
|
||||||
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams
|
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 model: SendModel = getOrCreateModel(params = params, router = innerRouter)
|
||||||
|
|
||||||
|
private val editReturnTracker = EditReturnTracker<CommonSendRoute> { it.isEditMode }
|
||||||
|
|
||||||
private val childStack = childStack(
|
private val childStack = childStack(
|
||||||
key = "sendInnerStack",
|
key = "sendInnerStack",
|
||||||
source = stackNavigation,
|
source = stackNavigation,
|
||||||
|
|
@ -83,6 +86,7 @@ internal class DefaultSendComponent @AssistedInject constructor(
|
||||||
lifecycle = lifecycle,
|
lifecycle = lifecycle,
|
||||||
mode = ObserveLifecycleMode.CREATE_DESTROY,
|
mode = ObserveLifecycleMode.CREATE_DESTROY,
|
||||||
) { stack ->
|
) { stack ->
|
||||||
|
val isReturnedFromEdit = editReturnTracker.onRouteActivated(stack.active.configuration)
|
||||||
when (val activeComponent = stack.active.instance) {
|
when (val activeComponent = stack.active.instance) {
|
||||||
is SendConfirmComponent -> {
|
is SendConfirmComponent -> {
|
||||||
val fromCurrency = params.currency
|
val fromCurrency = params.currency
|
||||||
|
|
@ -99,8 +103,9 @@ internal class DefaultSendComponent @AssistedInject constructor(
|
||||||
type = model.consumeEntryType(),
|
type = model.consumeEntryType(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (childStack.value.active.configuration.isEditMode) {
|
// A reused Confirm gets no constructor state — re-push the edited fields on edit-return
|
||||||
activeComponent.updateState(model.uiState.value)
|
if (isReturnedFromEdit) {
|
||||||
|
activeComponent.updateEditedState(model.uiState.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is SendAmountComponent -> {
|
is SendAmountComponent -> {
|
||||||
|
|
|
||||||
|
|
@ -137,11 +137,10 @@ internal class SendConfirmComponent(
|
||||||
}.launchIn(componentScope)
|
}.launchIn(componentScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateState(state: SendUM) {
|
fun updateEditedState(state: SendUM) {
|
||||||
destinationBlockComponent.updateState(state.destinationUM)
|
destinationBlockComponent.updateState(state.destinationUM)
|
||||||
amountBlockComponent.updateState(state.amountUM)
|
amountBlockComponent.updateState(state.amountUM)
|
||||||
feeSelectorBlockComponent.updateState(state.feeSelectorUM)
|
model.updateEditedState(state)
|
||||||
model.updateState(state)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -166,8 +166,13 @@ internal class SendConfirmModel @Inject constructor(
|
||||||
subscribeOnTapHelpUpdates()
|
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()
|
onFeeReload()
|
||||||
updateConfirmNotifications()
|
updateConfirmNotifications()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import com.tangem.core.ui.decompose.ComposableModularContentComponent
|
||||||
import com.tangem.core.ui.extensions.resourceReference
|
import com.tangem.core.ui.extensions.resourceReference
|
||||||
import com.tangem.features.send.api.NFTSendComponent
|
import com.tangem.features.send.api.NFTSendComponent
|
||||||
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
|
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.DestinationRoute
|
||||||
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent
|
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent
|
||||||
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams
|
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 model: NFTSendModel = getOrCreateModel(params = params, router = innerRouter)
|
||||||
|
|
||||||
|
private val editReturnTracker = EditReturnTracker<CommonSendRoute> { it.isEditMode }
|
||||||
|
|
||||||
private val childStack = childStack(
|
private val childStack = childStack(
|
||||||
key = "NFTSendInnerStack",
|
key = "NFTSendInnerStack",
|
||||||
source = stackNavigation,
|
source = stackNavigation,
|
||||||
|
|
@ -75,6 +78,7 @@ internal class DefaultNFTSendComponent @AssistedInject constructor(
|
||||||
lifecycle = lifecycle,
|
lifecycle = lifecycle,
|
||||||
mode = ObserveLifecycleMode.CREATE_DESTROY,
|
mode = ObserveLifecycleMode.CREATE_DESTROY,
|
||||||
) { stack ->
|
) { stack ->
|
||||||
|
val isReturnedFromEdit = editReturnTracker.onRouteActivated(stack.active.configuration)
|
||||||
when (val activeComponent = stack.active.instance) {
|
when (val activeComponent = stack.active.instance) {
|
||||||
is NFTSendConfirmComponent -> {
|
is NFTSendConfirmComponent -> {
|
||||||
val fromCurrency = model.cryptoCurrency
|
val fromCurrency = model.cryptoCurrency
|
||||||
|
|
@ -90,9 +94,9 @@ internal class DefaultNFTSendComponent @AssistedInject constructor(
|
||||||
toDerivationIndex = null,
|
toDerivationIndex = null,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Push current state into a reused Confirm on (re)entry. Confirm.isEditMode is `true`
|
// A reused Confirm gets no constructor state — re-push the edited fields on edit-return
|
||||||
if (stack.active.configuration.isEditMode) {
|
if (isReturnedFromEdit) {
|
||||||
activeComponent.updateState(model.uiState.value)
|
activeComponent.updateEditedState(model.uiState.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is SendDestinationComponent -> {
|
is SendDestinationComponent -> {
|
||||||
|
|
|
||||||
|
|
@ -134,9 +134,9 @@ internal class NFTSendConfirmComponent @AssistedInject constructor(
|
||||||
}.launchIn(componentScope)
|
}.launchIn(componentScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateState(state: NFTSendUM) {
|
fun updateEditedState(state: NFTSendUM) {
|
||||||
destinationBlockComponent.updateState(state.destinationUM)
|
destinationBlockComponent.updateState(state.destinationUM)
|
||||||
model.updateState(state)
|
model.updateEditedState(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -127,8 +127,13 @@ internal class NFTSendConfirmModel @Inject constructor(
|
||||||
initialState()
|
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()
|
updateConfirmNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import arrow.core.right
|
||||||
import com.tangem.blockchain.common.Amount
|
import com.tangem.blockchain.common.Amount
|
||||||
import com.tangem.blockchain.common.transaction.Fee
|
import com.tangem.blockchain.common.transaction.Fee
|
||||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
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.common.ui.amountScreen.models.AmountState
|
||||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
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<AmountState.Data>(relaxed = true)
|
||||||
|
val editedDestination = mockk<DestinationUM.Content>(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<AmountState.Data>(relaxed = true),
|
||||||
|
destinationUM = mockk<DestinationUM.Content>(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
|
// region fixtures
|
||||||
|
|
||||||
private fun confirmParams(state: SendUM) = MutableParamsContainer(
|
private fun confirmParams(state: SendUM) = MutableParamsContainer(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.tangem.features.send.sendnft.confirm.model
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import arrow.core.left
|
import arrow.core.left
|
||||||
import arrow.core.right
|
import arrow.core.right
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
import com.tangem.blockchain.common.Amount
|
import com.tangem.blockchain.common.Amount
|
||||||
import com.tangem.blockchain.common.TransactionData
|
import com.tangem.blockchain.common.TransactionData
|
||||||
import com.tangem.blockchain.common.transaction.Fee
|
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<DestinationUM.Content>(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
|
// region fixtures
|
||||||
|
|
||||||
private fun TestScope.buildModel(
|
private fun TestScope.buildModel(
|
||||||
|
|
|
||||||
|
|
@ -61,33 +61,37 @@ Consistent `.v2` segment (unlike the legacy `features/swap` which uses `feature.
|
||||||
(`SwapAmountComponent.ModelCallback`, `SendDestinationComponent.ModelCallback`,
|
(`SwapAmountComponent.ModelCallback`, `SendDestinationComponent.ModelCallback`,
|
||||||
`SendWithSwapConfirmComponent.ModelCallback`). Holds the **aggregate** state:
|
`SendWithSwapConfirmComponent.ModelCallback`). Holds the **aggregate** state:
|
||||||
- `uiState: StateFlow<SendWithSwapUM>` — `{ amountUM, destinationUM, feeSelectorUM, confirmUM, navigationUM }`
|
- `uiState: StateFlow<SendWithSwapUM>` — `{ amountUM, destinationUM, feeSelectorUM, confirmUM, navigationUM }`
|
||||||
- `currentRoute: MutableStateFlow<SendWithSwapRoute>`
|
|
||||||
- `primaryCryptoCurrencyStatusFlow`, `primaryFeePaidCurrencyStatusFlow`, `accountFlow`,
|
- `primaryCryptoCurrencyStatusFlow`, `primaryFeePaidCurrencyStatusFlow`, `accountFlow`,
|
||||||
`isAccountModeFlow`, `isBalanceHiddenFlow` — read-only sources passed down to children as params.
|
`isAccountModeFlow`, `isBalanceHiddenFlow` — read-only sources passed down to children as params.
|
||||||
|
|
||||||
Child→parent merge callbacks:
|
Child→parent merge callbacks:
|
||||||
- `onAmountResult(amountUM)` → `uiState.copy(amountUM = …)`
|
- `onAmountResult(amountUM)` → `uiState.copy(amountUM = …)`
|
||||||
- `onDestinationResult(destinationUM)` → `uiState.copy(destinationUM = …)`
|
- `onDestinationResult(destinationUM)` → `uiState.copy(destinationUM = …)`
|
||||||
- `onResult(route, sendWithSwapUM)` → **`if (currentRoute.value == route) uiState.value = …`** (full replace,
|
- `onResult(sendWithSwapUM)` → `uiState.value = …` (full replace; used by Confirm to publish its full
|
||||||
route-guarded; used by Confirm to publish its full state back up)
|
state back up)
|
||||||
- `onNavigationResult(navigationUM)` → drives the shared footer button/app-bar.
|
- `onNavigationResult(navigationUM)` → drives the shared footer button/app-bar.
|
||||||
|
|
||||||
### childStack subscription = the state-sync mechanism (READ THIS)
|
### childStack subscription = the state-sync mechanism (READ THIS)
|
||||||
`DefaultSendWithSwapComponent.init { childStack.subscribe(CREATE_DESTROY) { stack → componentScope.launch { … } } }`:
|
`DefaultSendWithSwapComponent.init { childStack.subscribe(CREATE_DESTROY) { stack → … } }`:
|
||||||
on every active-child change it **pushes the parent's current snapshot into the newly-active child** and
|
on every active-child change it **pushes the parent's current snapshot into the newly-active child**:
|
||||||
then emits the new route:
|
|
||||||
```kotlin
|
```kotlin
|
||||||
when (active) {
|
val isReturnedFromEdit = editReturnTracker.onRouteActivated(stack.active.configuration) // synchronous!
|
||||||
is SwapAmountComponent -> active.updateState(uiState.value.amountUM)
|
componentScope.launch {
|
||||||
is SendDestinationComponent -> active.updateState(uiState.value.destinationUM) // screen
|
when (active) {
|
||||||
is SendWithSwapConfirmComponent ->
|
is SwapAmountComponent -> active.updateState(uiState.value.amountUM)
|
||||||
if (model.currentRoute.value.isEditMode) active.updateState(uiState.value) // ← gated!
|
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
|
The gate must be true exactly when returning to a *reused* Confirm from an edit step — the Confirm route
|
||||||
true exactly when returning to a *reused* Confirm from an edit step. In the linear flow Confirm is
|
itself has `isEditMode = false`, so the check reads the **previous** route via `EditReturnTracker`
|
||||||
re-created fresh from `params.sendWithSwapUM`, so no re-push is needed.
|
(`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
|
### Confirm: SendWithSwapConfirmComponent / SendWithSwapConfirmModel
|
||||||
`impl/.../sendviaswap/confirm/`. The Confirm screen embeds **read-only blocks** reused from send-v2:
|
`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
|
`enteredDestination`, `enteredMemo`, `fee`, statuses, quote, rateType, amountType, priceImpact from
|
||||||
`uiState`; this is what the transaction + notifications are built from.
|
`uiState`; this is what the transaction + notifications are built from.
|
||||||
- `onFeeResult/onAmountResult/onDestinationResult` — block callbacks copy into `uiState`.
|
- `onFeeResult/onAmountResult/onDestinationResult` — block callbacks copy into `uiState`.
|
||||||
- `updateState(sendWithSwapUM)` — full replace (used by the edit-mode re-push).
|
- `updateEditedState(sendWithSwapUM)` — the edit-return re-push; copies ONLY the parent-owned fields
|
||||||
- `configConfirmNavigation` — `combine(uiState, currentRoute).filter { route is Confirm }` →
|
(`amountUM`, `destinationUM`). Confirm-local `confirmUM`/`feeSelectorUM` must never be overwritten
|
||||||
`callback.onResult(Confirm, state.copy(navigationUM = …))` (publishes confirm state up to the parent).
|
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 →
|
- Sending: `SwapTransactionSender` (CEX only; DEX/DEX_BRIDGE/ONRAMP rejected). Success →
|
||||||
`SendWithSwapConfirmSentStateTransformer` + `router.replaceAll(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
|
`saveResult()` (`SendDestinationModel.configDestinationNavigation`, `if (!route.isEditMode)`), so the
|
||||||
parent keeps the pre-edit value. The footer "Continue"/"Next" button always persists. This is shared
|
parent keeps the pre-edit value. The footer "Continue"/"Next" button always persists. This is shared
|
||||||
by regular Send + NFT Send + SvS.
|
by regular Send + NFT Send + SvS.
|
||||||
- **`onResult` is route-guarded.** `SendWithSwapModel.onResult` only applies when
|
- **The Confirm re-push gate reads the *previous* route.** The reused-Confirm `updateState` in the
|
||||||
`currentRoute.value == route`, which protects against late/stale Confirm emissions overwriting the
|
childStack subscription is gated on `EditReturnTracker.onRouteActivated(...)` (send api), which reports
|
||||||
parent after navigating away. Keep that guard if you refactor.
|
whether the route active *before* the new one was an edit route. Gating on the new active
|
||||||
- **`currentRoute.emit` runs at the END of the subscribe coroutine**, so the `isEditMode` re-push gate
|
configuration (`Confirm.isEditMode` — always `false`) silently disables the re-push ([REDACTED_TASK_KEY]).
|
||||||
reads the *previous* route. Relies on `componentScope` launches being serialized (main dispatcher).
|
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
|
- **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
|
only known after exchange-data, so confirm notifications pass `destinationAddress = null` for the
|
||||||
send-notifications path.
|
send-notifications path.
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import com.tangem.core.ui.extensions.resourceReference
|
||||||
import com.tangem.domain.swap.models.R
|
import com.tangem.domain.swap.models.R
|
||||||
import com.tangem.domain.swap.models.SwapDirection
|
import com.tangem.domain.swap.models.SwapDirection
|
||||||
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
|
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.DestinationRoute
|
||||||
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent
|
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent
|
||||||
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams
|
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 model: SendWithSwapModel = getOrCreateModel(params = params, router = innerRouter)
|
||||||
|
|
||||||
|
private val editReturnTracker = EditReturnTracker<SendWithSwapRoute> { it.isEditMode }
|
||||||
|
|
||||||
private val childStack = childStack(
|
private val childStack = childStack(
|
||||||
key = "sendWithSwapInnerStack",
|
key = "sendWithSwapInnerStack",
|
||||||
source = stackNavigation,
|
source = stackNavigation,
|
||||||
|
|
@ -79,6 +82,8 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
|
||||||
lifecycle = lifecycle,
|
lifecycle = lifecycle,
|
||||||
mode = ObserveLifecycleMode.CREATE_DESTROY,
|
mode = ObserveLifecycleMode.CREATE_DESTROY,
|
||||||
) { stack ->
|
) { stack ->
|
||||||
|
// Read synchronously: coroutine scheduling must not reorder tracker updates between stack events
|
||||||
|
val isReturnedFromEdit = editReturnTracker.onRouteActivated(stack.active.configuration)
|
||||||
componentScope.launch {
|
componentScope.launch {
|
||||||
when (val activeComponent = stack.active.instance) {
|
when (val activeComponent = stack.active.instance) {
|
||||||
is SwapAmountComponent -> {
|
is SwapAmountComponent -> {
|
||||||
|
|
@ -94,8 +99,9 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
|
||||||
activeComponent.updateState(model.uiState.value.destinationUM)
|
activeComponent.updateState(model.uiState.value.destinationUM)
|
||||||
}
|
}
|
||||||
is SendWithSwapConfirmComponent -> {
|
is SendWithSwapConfirmComponent -> {
|
||||||
if (stack.active.configuration.isEditMode) {
|
// A reused Confirm gets no constructor state — re-push the edited fields on edit-return
|
||||||
activeComponent.updateState(model.uiState.value)
|
if (isReturnedFromEdit) {
|
||||||
|
activeComponent.updateEditedState(model.uiState.value)
|
||||||
}
|
}
|
||||||
// Re-sync destination from parent on Confirm entry, bypassing the edit-mode gate ([REDACTED_TASK_KEY]).
|
// Re-sync destination from parent on Confirm entry, bypassing the edit-mode gate ([REDACTED_TASK_KEY]).
|
||||||
activeComponent.updateDestinationState(model.uiState.value.destinationUM)
|
activeComponent.updateDestinationState(model.uiState.value.destinationUM)
|
||||||
|
|
|
||||||
|
|
@ -179,10 +179,9 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
|
||||||
.launchIn(componentScope)
|
.launchIn(componentScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateState(sendWithSwapUM: SendWithSwapUM) {
|
fun updateEditedState(sendWithSwapUM: SendWithSwapUM) {
|
||||||
amountBlockComponent.updateState(sendWithSwapUM.amountUM)
|
amountBlockComponent.updateState(sendWithSwapUM.amountUM)
|
||||||
feeSelectorBlockComponent.updateState(sendWithSwapUM.feeSelectorUM)
|
model.updateEditedState(sendWithSwapUM)
|
||||||
model.updateState(sendWithSwapUM)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-sync destination from parent on Confirm entry, bypassing the edit-mode gate; Empty only occurs on
|
// Re-sync destination from parent on Confirm entry, bypassing the edit-mode gate; Empty only occurs on
|
||||||
|
|
|
||||||
|
|
@ -196,8 +196,19 @@ internal class SendWithSwapConfirmModel @Inject constructor(
|
||||||
updateConfirmNotifications()
|
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() {
|
override fun onFeeReload() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue