From b51e2ecf5864eb5b7da6dedbca29ea87ca74c348 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Wed, 15 Jul 2026 06:10:41 -0700 Subject: [PATCH] Add real deposit-wait progress to Bridge screen, reusing the swap timer UX Tapping Swap on the Bridge screen previously gave zero feedback afterward - the same countdown/success UX already used for swap execution didn't exist here, so a user had no way to tell whether anything was happening. Moved ExecutionTimerView (+ its layout) from feature-swap-impl to common, since it was already fully self-contained (no swap-specific coupling, all its resources already lived in common) - both features now share the one component instead of duplicating it. Wired into BridgeViewModel: after a swap is submitted, actively observe the destination balance (walletInteractor.assetFlow) for a real increase within a bounded window, showing the countdown while watching and Success only on a confirmed balance delta - never a cosmetic timer that "completes" regardless of whether funds arrived. If the window elapses without confirmation, it says so plainly (may need manual 3-of-5 review) rather than showing a false success or a false error. --- .../nova/common/view}/ExecutionTimerView.kt | 13 ++- .../main/res/layout/view_execution_timer.xml | 2 +- common/src/main/res/values/strings.xml | 3 + .../presentation/bridge/BridgeFragment.kt | 13 +++ .../presentation/bridge/BridgeViewModel.kt | 93 +++++++++++++++++-- .../src/main/res/layout/fragment_bridge.xml | 22 +++++ .../execution/SwapExecutionFragment.kt | 1 + .../res/layout/fragment_swap_execution.xml | 2 +- 8 files changed, 134 insertions(+), 15 deletions(-) rename {feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution => common/src/main/java/io/novafoundation/nova/common/view}/ExecutionTimerView.kt (92%) rename {feature-swap-impl => common}/src/main/res/layout/view_execution_timer.xml (99%) diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/ExecutionTimerView.kt b/common/src/main/java/io/novafoundation/nova/common/view/ExecutionTimerView.kt similarity index 92% rename from feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/ExecutionTimerView.kt rename to common/src/main/java/io/novafoundation/nova/common/view/ExecutionTimerView.kt index 1520728a..fbf72e2d 100644 --- a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/ExecutionTimerView.kt +++ b/common/src/main/java/io/novafoundation/nova/common/view/ExecutionTimerView.kt @@ -1,4 +1,4 @@ -package io.novafoundation.nova.feature_swap_impl.presentation.execution +package io.novafoundation.nova.common.view import android.content.Context import android.os.CountDownTimer @@ -14,13 +14,13 @@ import android.view.animation.RotateAnimation import android.widget.TextSwitcher import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewExecutionTimerBinding import io.novafoundation.nova.common.utils.WithContextExtensions import io.novafoundation.nova.common.utils.inflater import io.novafoundation.nova.common.utils.makeGone import io.novafoundation.nova.common.utils.makeVisible import io.novafoundation.nova.common.utils.setTextColorRes -import io.novafoundation.nova.feature_swap_impl.R -import io.novafoundation.nova.feature_swap_impl.databinding.ViewExecutionTimerBinding import kotlin.math.cos import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -28,6 +28,13 @@ import kotlin.time.Duration.Companion.milliseconds private const val SECOND_MILLIS = 1000L private const val HIDE_SCALE = 0.7f +/** + * Reusable circular countdown/result indicator - originally swap-execution-only + * (feature_swap_impl.presentation.execution), moved here since it has no swap-specific coupling + * beyond a Duration input, so other features (e.g. the Bridge screen) can show the same + * waiting/success/error UX instead of leaving the user with no feedback after submitting an + * operation that takes real time to confirm. + */ class ExecutionTimerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, diff --git a/feature-swap-impl/src/main/res/layout/view_execution_timer.xml b/common/src/main/res/layout/view_execution_timer.xml similarity index 99% rename from feature-swap-impl/src/main/res/layout/view_execution_timer.xml rename to common/src/main/res/layout/view_execution_timer.xml index dd1197ef..9404bad7 100644 --- a/feature-swap-impl/src/main/res/layout/view_execution_timer.xml +++ b/common/src/main/res/layout/view_execution_timer.xml @@ -64,4 +64,4 @@ app:layout_constraintStart_toStartOf="@+id/executionProgress" app:layout_constraintTop_toBottomOf="@+id/executionTimeSwitcher" /> - \ No newline at end of file + diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index a10dbc96..b6e2b622 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -372,6 +372,9 @@ Signing… Signing failed, tap to retry + Waiting for confirmation on Pezkuwi Asset Hub… + Still processing - this can take longer if it needs manual multisig review. Check back in a few minutes. + Buy/Sell diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt index 2c89a028..c961a6aa 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt @@ -156,6 +156,19 @@ class BridgeFragment : BaseFragment() { viewModel.fillAmountEvent.observeEvent { amount -> binder.bridgeFromCard.amountInput.setText(amount) } + + viewModel.depositWaitLabelVisible.observe { visible -> + binder.bridgeDepositWaitLabel.visibility = if (visible) View.VISIBLE else View.GONE + } + + viewModel.depositWaitState.observe { state -> + if (state == null) { + binder.bridgeExecutionTimer.visibility = View.GONE + } else { + binder.bridgeExecutionTimer.visibility = View.VISIBLE + binder.bridgeExecutionTimer.setState(state) + } + } } } diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt index c3a4ed6b..83842d64 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt @@ -8,6 +8,7 @@ import io.novafoundation.nova.common.resources.ResourceManager import io.novafoundation.nova.common.utils.Event import io.novafoundation.nova.common.utils.images.Icon import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.common.view.ExecutionTimerView import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback import io.novafoundation.nova.feature_assets.R import io.novafoundation.nova.feature_assets.domain.WalletInteractor @@ -23,9 +24,12 @@ import io.novafoundation.nova.runtime.ext.displayNameWithAssetStandard import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import java.math.BigDecimal import java.math.RoundingMode +import kotlin.time.Duration.Companion.seconds /** * DOT<->HEZ used to be a second pair here, retired 2026-07 in favor of the multisig-custodied @@ -68,6 +72,10 @@ class BridgeViewModel( // USDT has 6 decimals on both Polkadot and Pezkuwi Asset Hub. val USDT_DECIMALS_DIVISOR: BigDecimal = BigDecimal.TEN.pow(6) + + /** How long to actively watch the destination balance before admitting we can't confirm + * completion yet - not a claim about how long the bridge itself actually takes. */ + val DEPOSIT_WAIT_TIMEOUT = 90.seconds } private val _pair = MutableLiveData(BridgePair.USDT) @@ -109,6 +117,16 @@ class BridgeViewModel( private val _signButtonLabel = MutableLiveData("") val signButtonLabel: LiveData = _signButtonLabel + /** Null = hidden. Driven by an actual destination-balance observation after Swap is tapped - + * never just a cosmetic countdown that reports "done" regardless of whether funds arrived. */ + private val _depositWaitState = MutableLiveData(null) + val depositWaitState: LiveData = _depositWaitState + + private val _depositWaitLabelVisible = MutableLiveData(false) + val depositWaitLabelVisible: LiveData = _depositWaitLabelVisible + + private var depositWaitJob: Job? = null + private val _fromCard = MutableLiveData() val fromCard: LiveData = _fromCard @@ -193,17 +211,29 @@ class BridgeViewModel( val dir = _direction.value ?: return if (currentAmount <= 0) return + val chainId = when (dir) { + BridgeDirection.USDT_TO_WUSDT -> POLKADOT_ASSET_HUB_ID + BridgeDirection.WUSDT_TO_USDT -> PEZKUWI_ASSET_HUB_ID + } + + val assetId = when (dir) { + BridgeDirection.USDT_TO_WUSDT -> POLKADOT_USDT_ASSET_ID + BridgeDirection.WUSDT_TO_USDT -> PEZKUWI_USDT_ASSET_ID + } + + val destChainId = when (dir) { + BridgeDirection.USDT_TO_WUSDT -> PEZKUWI_ASSET_HUB_ID + BridgeDirection.WUSDT_TO_USDT -> POLKADOT_ASSET_HUB_ID + } + + val destAssetId = when (dir) { + BridgeDirection.USDT_TO_WUSDT -> PEZKUWI_USDT_ASSET_ID + BridgeDirection.WUSDT_TO_USDT -> POLKADOT_USDT_ASSET_ID + } + + val expectedAmount = currentAmount + launch { - val chainId = when (dir) { - BridgeDirection.USDT_TO_WUSDT -> POLKADOT_ASSET_HUB_ID - BridgeDirection.WUSDT_TO_USDT -> PEZKUWI_ASSET_HUB_ID - } - - val assetId = when (dir) { - BridgeDirection.USDT_TO_WUSDT -> POLKADOT_USDT_ASSET_ID - BridgeDirection.WUSDT_TO_USDT -> PEZKUWI_USDT_ASSET_ID - } - val chain = chainRegistry.getChain(chainId) val accountId = BRIDGE_ADDRESS_GENERIC.toAccountId() val bridgeAddress = chain.addressOf(accountId) @@ -212,6 +242,49 @@ class BridgeViewModel( val sendPayload = SendPayload.SpecifiedOrigin(assetPayload) router.openSend(sendPayload, bridgeAddress, currentAmount) + + observeDepositCompletion(destChainId, destAssetId, expectedAmount) + } + } + + /** Watches the DESTINATION balance for a real increase after a swap is submitted, rather than + * a cosmetic timer that reports "done" regardless of whether funds actually arrived - there is + * no completion callback from the generic Send flow this screen delegates to (it just pops + * back to the previous screen on success), so balance-delta is the only honest signal + * available. Bounded to a reasonable wait; if it elapses without a confirmed increase, this + * says so plainly rather than implying success or failure it can't actually confirm - the + * swap may just need manual 3-of-5 review, which can take longer. + */ + private fun observeDepositCompletion(destChainId: String, destAssetId: Int, expectedAmount: Double) { + depositWaitJob?.cancel() + depositWaitJob = launch { + val balanceBefore = try { + walletInteractor.assetFlow(destChainId, destAssetId).first().transferable + } catch (e: Exception) { + return@launch // Can't observe reliably - don't show a misleading progress state. + } + + _depositWaitLabelVisible.postValue(true) + _depositWaitState.postValue(ExecutionTimerView.State.CountdownTimer(DEPOSIT_WAIT_TIMEOUT)) + + val minExpectedIncrease = BigDecimal.valueOf(expectedAmount * (1 - FEE_PERCENT * 2)) // fee + rounding slack + + val confirmed = withTimeoutOrNull(DEPOSIT_WAIT_TIMEOUT.inWholeMilliseconds) { + walletInteractor.assetFlow(destChainId, destAssetId) + .first { it.transferable - balanceBefore >= minExpectedIncrease } + } != null + + if (confirmed) { + _depositWaitState.postValue(ExecutionTimerView.State.Success) + } else { + // Not a failure - just not confirmed within the wait window. Hide the timer and + // say so plainly instead of showing a false success or a false error icon. + _depositWaitState.postValue(null) + _depositWaitLabelVisible.postValue(false) + _warningBlocked.postValue(false) + _showWarning.postValue(true) + _warningText.postValue(resourceManager.getString(R.string.bridge_deposit_pending_message)) + } } } diff --git a/feature-assets/src/main/res/layout/fragment_bridge.xml b/feature-assets/src/main/res/layout/fragment_bridge.xml index 59feac79..2e72f57b 100644 --- a/feature-assets/src/main/res/layout/fragment_bridge.xml +++ b/feature-assets/src/main/res/layout/fragment_bridge.xml @@ -190,6 +190,28 @@ android:text="@string/bridge_sign_button" android:visibility="gone" /> + + + + + diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/SwapExecutionFragment.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/SwapExecutionFragment.kt index da85e2ba..9b6db9b5 100644 --- a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/SwapExecutionFragment.kt +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/SwapExecutionFragment.kt @@ -12,6 +12,7 @@ import io.novafoundation.nova.common.utils.makeVisible import io.novafoundation.nova.common.utils.setCurrentText import io.novafoundation.nova.common.utils.setText import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.ExecutionTimerView import io.novafoundation.nova.common.view.bottomSheet.description.observeDescription import io.novafoundation.nova.common.view.shape.getBlockDrawable import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable diff --git a/feature-swap-impl/src/main/res/layout/fragment_swap_execution.xml b/feature-swap-impl/src/main/res/layout/fragment_swap_execution.xml index d11ca294..72e6b705 100644 --- a/feature-swap-impl/src/main/res/layout/fragment_swap_execution.xml +++ b/feature-swap-impl/src/main/res/layout/fragment_swap_execution.xml @@ -27,7 +27,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> -