Updated on 2026-08-14
This commit is contained in:
parent
d792ac6304
commit
f61a462cc3
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.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<CommonSendRoute> { 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 -> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CommonSendRoute> { 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 -> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
||||
private fun confirmParams(state: SendUM) = MutableParamsContainer(
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
||||
private fun TestScope.buildModel(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue