mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 16:05:49 +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:
-221
@@ -1,221 +0,0 @@
|
||||
package io.novafoundation.nova.feature_swap_impl.presentation.execution
|
||||
|
||||
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.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
|
||||
|
||||
private const val SECOND_MILLIS = 1000L
|
||||
private const val HIDE_SCALE = 0.7f
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+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.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
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
app:layout_constraintStart_toStartOf="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:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<?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>
|
||||
Reference in New Issue
Block a user