mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 19:35:51 +00:00
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.
This commit is contained in:
+10
-3
@@ -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.content.Context
|
||||||
import android.os.CountDownTimer
|
import android.os.CountDownTimer
|
||||||
@@ -14,13 +14,13 @@ import android.view.animation.RotateAnimation
|
|||||||
import android.widget.TextSwitcher
|
import android.widget.TextSwitcher
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
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.WithContextExtensions
|
||||||
import io.novafoundation.nova.common.utils.inflater
|
import io.novafoundation.nova.common.utils.inflater
|
||||||
import io.novafoundation.nova.common.utils.makeGone
|
import io.novafoundation.nova.common.utils.makeGone
|
||||||
import io.novafoundation.nova.common.utils.makeVisible
|
import io.novafoundation.nova.common.utils.makeVisible
|
||||||
import io.novafoundation.nova.common.utils.setTextColorRes
|
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.math.cos
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
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 SECOND_MILLIS = 1000L
|
||||||
private const val HIDE_SCALE = 0.7f
|
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(
|
class ExecutionTimerView @JvmOverloads constructor(
|
||||||
context: Context,
|
context: Context,
|
||||||
attrs: AttributeSet? = null,
|
attrs: AttributeSet? = null,
|
||||||
+1
-1
@@ -64,4 +64,4 @@
|
|||||||
app:layout_constraintStart_toStartOf="@+id/executionProgress"
|
app:layout_constraintStart_toStartOf="@+id/executionProgress"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/executionTimeSwitcher" />
|
app:layout_constraintTop_toBottomOf="@+id/executionTimeSwitcher" />
|
||||||
|
|
||||||
</merge>
|
</merge>
|
||||||
@@ -372,6 +372,9 @@
|
|||||||
<string name="bridge_sign_in_progress">Signing…</string>
|
<string name="bridge_sign_in_progress">Signing…</string>
|
||||||
<string name="bridge_sign_error">Signing failed, tap to retry</string>
|
<string name="bridge_sign_error">Signing failed, tap to retry</string>
|
||||||
|
|
||||||
|
<string name="bridge_deposit_wait_label">Waiting for confirmation on Pezkuwi Asset Hub…</string>
|
||||||
|
<string name="bridge_deposit_pending_message">Still processing - this can take longer if it needs manual multisig review. Check back in a few minutes.</string>
|
||||||
|
|
||||||
<string name="wallet_asset_buy_sell">Buy/Sell</string>
|
<string name="wallet_asset_buy_sell">Buy/Sell</string>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+13
@@ -156,6 +156,19 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
|
|||||||
viewModel.fillAmountEvent.observeEvent { amount ->
|
viewModel.fillAmountEvent.observeEvent { amount ->
|
||||||
binder.bridgeFromCard.amountInput.setText(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+83
-10
@@ -8,6 +8,7 @@ import io.novafoundation.nova.common.resources.ResourceManager
|
|||||||
import io.novafoundation.nova.common.utils.Event
|
import io.novafoundation.nova.common.utils.Event
|
||||||
import io.novafoundation.nova.common.utils.images.Icon
|
import io.novafoundation.nova.common.utils.images.Icon
|
||||||
import io.novafoundation.nova.common.view.ButtonState
|
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_account_api.presenatation.chain.getAssetIconOrFallback
|
||||||
import io.novafoundation.nova.feature_assets.R
|
import io.novafoundation.nova.feature_assets.R
|
||||||
import io.novafoundation.nova.feature_assets.domain.WalletInteractor
|
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.novafoundation.nova.runtime.multiNetwork.ChainRegistry
|
||||||
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId
|
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.math.RoundingMode
|
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
|
* 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.
|
// USDT has 6 decimals on both Polkadot and Pezkuwi Asset Hub.
|
||||||
val USDT_DECIMALS_DIVISOR: BigDecimal = BigDecimal.TEN.pow(6)
|
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)
|
private val _pair = MutableLiveData(BridgePair.USDT)
|
||||||
@@ -109,6 +117,16 @@ class BridgeViewModel(
|
|||||||
private val _signButtonLabel = MutableLiveData("")
|
private val _signButtonLabel = MutableLiveData("")
|
||||||
val signButtonLabel: LiveData<String> = _signButtonLabel
|
val signButtonLabel: LiveData<String> = _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<ExecutionTimerView.State?>(null)
|
||||||
|
val depositWaitState: LiveData<ExecutionTimerView.State?> = _depositWaitState
|
||||||
|
|
||||||
|
private val _depositWaitLabelVisible = MutableLiveData(false)
|
||||||
|
val depositWaitLabelVisible: LiveData<Boolean> = _depositWaitLabelVisible
|
||||||
|
|
||||||
|
private var depositWaitJob: Job? = null
|
||||||
|
|
||||||
private val _fromCard = MutableLiveData<BridgeAssetCardUi>()
|
private val _fromCard = MutableLiveData<BridgeAssetCardUi>()
|
||||||
val fromCard: LiveData<BridgeAssetCardUi> = _fromCard
|
val fromCard: LiveData<BridgeAssetCardUi> = _fromCard
|
||||||
|
|
||||||
@@ -193,17 +211,29 @@ class BridgeViewModel(
|
|||||||
val dir = _direction.value ?: return
|
val dir = _direction.value ?: return
|
||||||
if (currentAmount <= 0) 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 {
|
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 chain = chainRegistry.getChain(chainId)
|
||||||
val accountId = BRIDGE_ADDRESS_GENERIC.toAccountId()
|
val accountId = BRIDGE_ADDRESS_GENERIC.toAccountId()
|
||||||
val bridgeAddress = chain.addressOf(accountId)
|
val bridgeAddress = chain.addressOf(accountId)
|
||||||
@@ -212,6 +242,49 @@ class BridgeViewModel(
|
|||||||
val sendPayload = SendPayload.SpecifiedOrigin(assetPayload)
|
val sendPayload = SendPayload.SpecifiedOrigin(assetPayload)
|
||||||
|
|
||||||
router.openSend(sendPayload, bridgeAddress, currentAmount)
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,28 @@
|
|||||||
android:text="@string/bridge_sign_button"
|
android:text="@string/bridge_sign_button"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<!-- Deposit-wait progress: shown after tapping Swap, while we actively watch the
|
||||||
|
destination balance for a real confirmed increase - not a cosmetic timer that
|
||||||
|
completes regardless of whether funds actually arrived. Same reusable component
|
||||||
|
already used for swap execution (common.view.ExecutionTimerView). -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bridgeDepositWaitLabel"
|
||||||
|
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/bridge_deposit_wait_label"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<io.novafoundation.nova.common.view.ExecutionTimerView
|
||||||
|
android:id="@+id/bridgeExecutionTimer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
+1
@@ -12,6 +12,7 @@ import io.novafoundation.nova.common.utils.makeVisible
|
|||||||
import io.novafoundation.nova.common.utils.setCurrentText
|
import io.novafoundation.nova.common.utils.setCurrentText
|
||||||
import io.novafoundation.nova.common.utils.setText
|
import io.novafoundation.nova.common.utils.setText
|
||||||
import io.novafoundation.nova.common.utils.setTextColorRes
|
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.bottomSheet.description.observeDescription
|
||||||
import io.novafoundation.nova.common.view.shape.getBlockDrawable
|
import io.novafoundation.nova.common.view.shape.getBlockDrawable
|
||||||
import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable
|
import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
<io.novafoundation.nova.feature_swap_impl.presentation.execution.ExecutionTimerView
|
<io.novafoundation.nova.common.view.ExecutionTimerView
|
||||||
android:id="@+id/swapExecutionTimer"
|
android:id="@+id/swapExecutionTimer"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
|||||||
Reference in New Issue
Block a user