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:
2026-07-15 06:10:41 -07:00
parent a5454b38f0
commit b51e2ecf58
8 changed files with 134 additions and 15 deletions
@@ -0,0 +1,228 @@
package io.novafoundation.nova.common.view
import android.content.Context
import android.os.CountDownTimer
import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import android.view.animation.BaseInterpolator
import android.view.animation.DecelerateInterpolator
import android.view.animation.OvershootInterpolator
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 kotlin.math.cos
import kotlin.time.Duration
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,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) {
sealed interface State {
object Success : State
object Error : State
class CountdownTimer(val duration: Duration) : State
}
private val binder = ViewExecutionTimerBinding.inflate(inflater(), this)
private var currentState: State? = null
private val slideTopInAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_slide_bottom_in)
private val slideTopOutAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_slide_bottom_out)
private var currentTimer: CountDownTimer? = null
init {
setupTimerSwitcher()
}
fun setState(state: State) {
currentState = state
currentTimer?.cancel()
when (state) {
State.Success -> {
hideTimerWithAnimation()
binder.executionResult.setImageResource(R.drawable.ic_execution_result_success)
binder.executionResult.fadeInWithScale()
}
State.Error -> {
hideTimerWithAnimation()
binder.executionResult.setImageResource(R.drawable.ic_execution_result_error)
binder.executionResult.fadeInWithScale()
}
is State.CountdownTimer -> {
binder.executionResult.fadeOutWithScale()
showTimerWithAnimation()
binder.executionProgress.runInfinityRotationAnimation()
// We add delay to match progress animation perfectly
// Text should be switched in the middle of a progress animation with small offset
val middleOfAnimation = SECOND_MILLIS / 2
val switchAnimationOffset = slideTopOutAnimation.duration / 2
val delay = middleOfAnimation - switchAnimationOffset
currentTimer = CountdownSwitcherTimer(binder.executionTimeSwitcher, state.duration)
runTimerWithDelay(delay, currentTimer!!)
}
}
}
private fun hideTimerWithAnimation() {
binder.executionProgress.fadeOut()
binder.executionTimeSwitcher.fadeOutWithScale()
binder.executionTimeSeconds.fadeOutWithScale()
}
private fun showTimerWithAnimation() {
binder.executionProgress.fadeIn()
binder.executionTimeSwitcher.fadeInWithScale()
binder.executionTimeSeconds.fadeInWithScale()
}
private fun runTimerWithDelay(delay: Long, timer: CountDownTimer) {
postDelayed({ timer.start() }, delay)
}
private fun setupTimerSwitcher() {
binder.executionTimeSwitcher.setFactory {
val textView = TextView(context, null, 0, R.style.TextAppearance_NovaFoundation_Bold_Title3)
textView.setGravity(Gravity.CENTER)
textView.setTextColorRes(R.color.text_primary)
textView.includeFontPadding = false
textView
}
binder.executionTimeSwitcher.inAnimation = slideTopInAnimation
binder.executionTimeSwitcher.outAnimation = slideTopOutAnimation
}
private fun View.runInfinityRotationAnimation() {
val anim = RotateAnimation(
0f,
-360f,
Animation.RELATIVE_TO_SELF,
0.5f,
Animation.RELATIVE_TO_SELF,
0.5f
)
anim.duration = SECOND_MILLIS
anim.repeatCount = Animation.INFINITE
anim.interpolator = StartSpeedAccelerateDecelerateInterpolator()
startAnimation(anim)
}
private fun View.fadeOut() {
animate()
.alpha(0f)
.setDuration(400)
.withEndAction { makeGone() }
.setInterpolator(DecelerateInterpolator())
.start()
}
private fun View.fadeIn() {
alpha = 0f
makeVisible()
animate()
.alpha(1f)
.setDuration(400)
.setInterpolator(DecelerateInterpolator())
.start()
}
private fun View.fadeOutWithScale() {
animate()
.alpha(0f)
.scaleX(HIDE_SCALE)
.scaleY(HIDE_SCALE)
.setDuration(400)
.withEndAction { makeGone() }
.setInterpolator(DecelerateInterpolator())
.start()
}
private fun View.fadeInWithScale() {
scaleX = HIDE_SCALE
scaleY = HIDE_SCALE
alpha = 0f
makeVisible()
animate()
.alpha(1f)
.scaleX(1f)
.scaleY(1f)
.setDuration(400)
.setInterpolator(OvershootInterpolator())
.start()
}
}
private class StartSpeedAccelerateDecelerateInterpolator : BaseInterpolator() {
override fun getInterpolation(input: Float): Float {
val speed = 0.085 // A constant
val result = input + cos((input * 2 * Math.PI) + Math.PI / 2) * speed
return result.toFloat()
}
}
private class CountdownSwitcherTimer(val switcher: TextSwitcher, duration: Duration) :
CountDownTimer(
duration.inWholeMilliseconds + SECOND_MILLIS, // Add a seconds to show max value to user
SECOND_MILLIS
) {
init {
switcher.setText(duration.inWholeSeconds.toString())
}
override fun onTick(millisUntilFinished: Long) {
val duration = millisUntilFinished.milliseconds
val seconds = duration.inWholeSeconds.toString()
if (shouldPlayAnimation(seconds)) {
switcher.setText(seconds)
}
}
override fun onFinish() {
// Nothing to do
}
private fun shouldPlayAnimation(seconds: String): Boolean {
val currentTextView = switcher.currentView as? TextView ?: return true
return currentTextView.text != seconds
}
}
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
tools:background="@color/secondary_screen_background"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<View
android:id="@+id/executionBackground"
android:layout_width="64dp"
android:layout_height="64dp"
android:background="@drawable/bg_container_with_border_circle"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/executionProgress"
android:layout_width="0dp"
android:layout_height="0dp"
android:src="@drawable/ic_container_timer_animated"
app:layout_constraintBottom_toBottomOf="@+id/executionBackground"
app:layout_constraintEnd_toEndOf="@+id/executionBackground"
app:layout_constraintStart_toStartOf="@+id/executionBackground"
app:layout_constraintTop_toTopOf="@+id/executionBackground" />
<ImageView
android:id="@+id/executionResult"
android:layout_width="0dp"
android:layout_height="0dp"
android:src="@drawable/ic_execution_result_success"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/executionBackground"
app:layout_constraintEnd_toEndOf="@+id/executionBackground"
app:layout_constraintStart_toStartOf="@+id/executionBackground"
app:layout_constraintTop_toTopOf="@+id/executionBackground" />
<TextSwitcher
android:id="@+id/executionTimeSwitcher"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="20dp"
android:textColor="@color/text_primary"
app:layout_constraintBottom_toTopOf="@+id/executionTimeSeconds"
app:layout_constraintEnd_toEndOf="@+id/executionProgress"
app:layout_constraintStart_toStartOf="@+id/executionProgress"
app:layout_constraintTop_toTopOf="@+id/executionProgress"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/executionTimeSeconds"
style="@style/TextAppearance.NovaFoundation.Regular.Caption1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:text="@string/common_sec"
android:textColor="@color/text_secondary"
app:layout_constraintBottom_toBottomOf="@+id/executionProgress"
app:layout_constraintEnd_toEndOf="@+id/executionProgress"
app:layout_constraintStart_toStartOf="@+id/executionProgress"
app:layout_constraintTop_toBottomOf="@+id/executionTimeSwitcher" />
</merge>
+3
View File
@@ -372,6 +372,9 @@
<string name="bridge_sign_in_progress">Signing…</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>