mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-23 14:15:42 +00:00
feat: block Bridge swap when send amount exceeds available balance
The Bridge screen let a user type any amount and enabled the Swap button regardless of what they actually held, unlike the Swap screen which shows a live "Max: X" and disables on overflow. The confirm-step ValidationSystem would still catch it before signing, but only after routing through Send - no immediate feedback on the Bridge screen itself. Wires WalletInteractor.assetFlow(chainId, assetId) to the currently selected origin side (re-subscribed on every direction/pair change), tracking Asset.transferable - the same field the real transfer validation checks. Reuses the existing MaxAmountView/MaxAvailableView widget the Swap screen already uses for the "Max: X" display, and adds a matching error-bordered state to BridgeAssetInputView. The "Insufficient balance" string (bridge_insufficient_balance) was already translated into every locale but never wired to any code.
This commit is contained in:
+17
@@ -10,6 +10,7 @@ import io.novafoundation.nova.common.view.setState
|
|||||||
import io.novafoundation.nova.feature_assets.databinding.FragmentBridgeBinding
|
import io.novafoundation.nova.feature_assets.databinding.FragmentBridgeBinding
|
||||||
import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi
|
import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi
|
||||||
import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent
|
import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.MaxActionAvailability
|
||||||
|
|
||||||
class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
|
class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
|
||||||
|
|
||||||
@@ -49,6 +50,10 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
binder.bridgeFromMaxAmount.setMaxActionAvailability(
|
||||||
|
MaxActionAvailability.Available { viewModel.maxClicked() }
|
||||||
|
)
|
||||||
|
|
||||||
// Swap button
|
// Swap button
|
||||||
binder.bridgeSwapButton.setOnClickListener {
|
binder.bridgeSwapButton.setOnClickListener {
|
||||||
viewModel.swapClicked()
|
viewModel.swapClicked()
|
||||||
@@ -105,6 +110,18 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
|
|||||||
binder.bridgeWarningAlert.setMessage(text)
|
binder.bridgeWarningAlert.setMessage(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewModel.maxAmountDisplay.observe { display ->
|
||||||
|
binder.bridgeFromMaxAmount.setMaxAmountDisplay(display)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.insufficientBalanceError.observe { error ->
|
||||||
|
binder.bridgeFromCard.setError(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.fillAmountEvent.observeEvent { amount ->
|
||||||
|
binder.bridgeFromCard.amountInput.setText(amount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+46
-1
@@ -5,10 +5,12 @@ import androidx.lifecycle.MutableLiveData
|
|||||||
import io.novafoundation.nova.common.base.BaseViewModel
|
import io.novafoundation.nova.common.base.BaseViewModel
|
||||||
import io.novafoundation.nova.common.presentation.AssetIconProvider
|
import io.novafoundation.nova.common.presentation.AssetIconProvider
|
||||||
import io.novafoundation.nova.common.resources.ResourceManager
|
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.utils.images.Icon
|
||||||
import io.novafoundation.nova.common.view.ButtonState
|
import io.novafoundation.nova.common.view.ButtonState
|
||||||
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.presentation.AssetsRouter
|
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
|
||||||
import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload
|
import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload
|
||||||
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
|
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
|
||||||
@@ -18,6 +20,7 @@ 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.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
@@ -29,7 +32,8 @@ class BridgeViewModel(
|
|||||||
private val router: AssetsRouter,
|
private val router: AssetsRouter,
|
||||||
private val resourceManager: ResourceManager,
|
private val resourceManager: ResourceManager,
|
||||||
private val chainRegistry: ChainRegistry,
|
private val chainRegistry: ChainRegistry,
|
||||||
private val assetIconProvider: AssetIconProvider
|
private val assetIconProvider: AssetIconProvider,
|
||||||
|
private val walletInteractor: WalletInteractor
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -90,10 +94,21 @@ class BridgeViewModel(
|
|||||||
private val _pairOptions = MutableLiveData<List<BridgePairUi>>(emptyList())
|
private val _pairOptions = MutableLiveData<List<BridgePairUi>>(emptyList())
|
||||||
val pairOptions: LiveData<List<BridgePairUi>> = _pairOptions
|
val pairOptions: LiveData<List<BridgePairUi>> = _pairOptions
|
||||||
|
|
||||||
|
private val _maxAmountDisplay = MutableLiveData<String?>(null)
|
||||||
|
val maxAmountDisplay: LiveData<String?> = _maxAmountDisplay
|
||||||
|
|
||||||
|
private val _insufficientBalanceError = MutableLiveData<String?>(null)
|
||||||
|
val insufficientBalanceError: LiveData<String?> = _insufficientBalanceError
|
||||||
|
|
||||||
|
private val _fillAmountEvent = MutableLiveData<Event<String>>()
|
||||||
|
val fillAmountEvent: LiveData<Event<String>> = _fillAmountEvent
|
||||||
|
|
||||||
private var currentAmount: Double = 0.0
|
private var currentAmount: Double = 0.0
|
||||||
private var dotToHezRate: Double = FALLBACK_RATE
|
private var dotToHezRate: Double = FALLBACK_RATE
|
||||||
private var isHezToDotActive: Boolean = false
|
private var isHezToDotActive: Boolean = false
|
||||||
private var isWusdtToUsdtActive: Boolean = false
|
private var isWusdtToUsdtActive: Boolean = false
|
||||||
|
private var availableBalance: BigDecimal = BigDecimal.ZERO
|
||||||
|
private var balanceJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
fetchExchangeRate()
|
fetchExchangeRate()
|
||||||
@@ -150,9 +165,14 @@ class BridgeViewModel(
|
|||||||
fun setAmount(amount: Double) {
|
fun setAmount(amount: Double) {
|
||||||
currentAmount = amount
|
currentAmount = amount
|
||||||
calculateOutput()
|
calculateOutput()
|
||||||
|
updateInsufficientBalanceState()
|
||||||
updateButtonState()
|
updateButtonState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun maxClicked() {
|
||||||
|
_fillAmountEvent.value = Event(availableBalance.stripTrailingZeros().toPlainString())
|
||||||
|
}
|
||||||
|
|
||||||
fun swapClicked() {
|
fun swapClicked() {
|
||||||
val dir = _direction.value ?: return
|
val dir = _direction.value ?: return
|
||||||
if (currentAmount <= 0) return
|
if (currentAmount <= 0) return
|
||||||
@@ -332,12 +352,21 @@ class BridgeViewModel(
|
|||||||
_buttonState.value = when {
|
_buttonState.value = when {
|
||||||
currentAmount <= 0 -> ButtonState.DISABLED
|
currentAmount <= 0 -> ButtonState.DISABLED
|
||||||
currentAmount < minimum -> ButtonState.DISABLED
|
currentAmount < minimum -> ButtonState.DISABLED
|
||||||
|
BigDecimal.valueOf(currentAmount) > availableBalance -> ButtonState.DISABLED
|
||||||
dir == BridgeDirection.HEZ_TO_DOT && !isHezToDotActive -> ButtonState.DISABLED
|
dir == BridgeDirection.HEZ_TO_DOT && !isHezToDotActive -> ButtonState.DISABLED
|
||||||
dir == BridgeDirection.WUSDT_TO_USDT && !isWusdtToUsdtActive -> ButtonState.DISABLED
|
dir == BridgeDirection.WUSDT_TO_USDT && !isWusdtToUsdtActive -> ButtonState.DISABLED
|
||||||
else -> ButtonState.NORMAL
|
else -> ButtonState.NORMAL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateInsufficientBalanceState() {
|
||||||
|
_insufficientBalanceError.value = if (currentAmount > 0 && BigDecimal.valueOf(currentAmount) > availableBalance) {
|
||||||
|
resourceManager.getString(R.string.bridge_insufficient_balance)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun refreshBridgeStatus() {
|
fun refreshBridgeStatus() {
|
||||||
fetchBridgeStatus()
|
fetchBridgeStatus()
|
||||||
}
|
}
|
||||||
@@ -370,6 +399,22 @@ class BridgeViewModel(
|
|||||||
_fromCard.value = cardUiFor(originChainId, originAssetId)
|
_fromCard.value = cardUiFor(originChainId, originAssetId)
|
||||||
_toCard.value = cardUiFor(destChainId, destAssetId)
|
_toCard.value = cardUiFor(destChainId, destAssetId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
observeOriginBalance(originChainId, originAssetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeOriginBalance(chainId: String, assetId: Int) {
|
||||||
|
balanceJob?.cancel()
|
||||||
|
balanceJob = launch {
|
||||||
|
walletInteractor.assetFlow(chainId, assetId).collect { asset ->
|
||||||
|
availableBalance = asset.transferable
|
||||||
|
_maxAmountDisplay.postValue(
|
||||||
|
"${availableBalance.setScale(6, RoundingMode.DOWN).stripTrailingZeros().toPlainString()} ${asset.token.configuration.symbol.value}"
|
||||||
|
)
|
||||||
|
updateInsufficientBalanceState()
|
||||||
|
updateButtonState()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadPairOptions() {
|
private fun loadPairOptions() {
|
||||||
|
|||||||
+4
-2
@@ -10,6 +10,7 @@ import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
|
|||||||
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
|
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
|
||||||
import io.novafoundation.nova.common.presentation.AssetIconProvider
|
import io.novafoundation.nova.common.presentation.AssetIconProvider
|
||||||
import io.novafoundation.nova.common.resources.ResourceManager
|
import io.novafoundation.nova.common.resources.ResourceManager
|
||||||
|
import io.novafoundation.nova.feature_assets.domain.WalletInteractor
|
||||||
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
|
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
|
||||||
import io.novafoundation.nova.feature_assets.presentation.bridge.BridgeViewModel
|
import io.novafoundation.nova.feature_assets.presentation.bridge.BridgeViewModel
|
||||||
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
|
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
|
||||||
@@ -24,9 +25,10 @@ class BridgeModule {
|
|||||||
router: AssetsRouter,
|
router: AssetsRouter,
|
||||||
resourceManager: ResourceManager,
|
resourceManager: ResourceManager,
|
||||||
chainRegistry: ChainRegistry,
|
chainRegistry: ChainRegistry,
|
||||||
assetIconProvider: AssetIconProvider
|
assetIconProvider: AssetIconProvider,
|
||||||
|
walletInteractor: WalletInteractor
|
||||||
): ViewModel {
|
): ViewModel {
|
||||||
return BridgeViewModel(router, resourceManager, chainRegistry, assetIconProvider)
|
return BridgeViewModel(router, resourceManager, chainRegistry, assetIconProvider, walletInteractor)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
+13
@@ -11,7 +11,9 @@ import io.novafoundation.nova.common.utils.WithContextExtensions
|
|||||||
import io.novafoundation.nova.common.utils.images.asUrlIcon
|
import io.novafoundation.nova.common.utils.images.asUrlIcon
|
||||||
import io.novafoundation.nova.common.utils.images.setIconOrMakeGone
|
import io.novafoundation.nova.common.utils.images.setIconOrMakeGone
|
||||||
import io.novafoundation.nova.common.utils.inflater
|
import io.novafoundation.nova.common.utils.inflater
|
||||||
|
import io.novafoundation.nova.common.utils.setVisible
|
||||||
import io.novafoundation.nova.common.view.shape.getInputBackground
|
import io.novafoundation.nova.common.view.shape.getInputBackground
|
||||||
|
import io.novafoundation.nova.common.view.shape.getInputBackgroundError
|
||||||
import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon
|
import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon
|
||||||
import io.novafoundation.nova.feature_assets.databinding.ViewBridgeAssetInputBinding
|
import io.novafoundation.nova.feature_assets.databinding.ViewBridgeAssetInputBinding
|
||||||
import io.novafoundation.nova.feature_assets.presentation.bridge.BridgeAssetCardUi
|
import io.novafoundation.nova.feature_assets.presentation.bridge.BridgeAssetCardUi
|
||||||
@@ -60,4 +62,15 @@ class BridgeAssetInputView @JvmOverloads constructor(
|
|||||||
binder.bridgeAssetInputSubtitle.text = model.chainName
|
binder.bridgeAssetInputSubtitle.text = model.chainName
|
||||||
binder.bridgeAssetInputSubtitleImage.setIconOrMakeGone(model.chainIconUrl?.asUrlIcon(), imageLoader)
|
binder.bridgeAssetInputSubtitleImage.setIconOrMakeGone(model.chainIconUrl?.asUrlIcon(), imageLoader)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setError(message: String?) {
|
||||||
|
binder.bridgeAssetInputContainer.background = if (message != null) {
|
||||||
|
context.getInputBackgroundError()
|
||||||
|
} else {
|
||||||
|
context.getInputBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
binder.bridgeAssetInputError.text = message
|
||||||
|
binder.bridgeAssetInputError.setVisible(message != null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,13 +25,27 @@
|
|||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="16dp">
|
android:padding="16dp">
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/bridgeFromLabel"
|
android:layout_width="match_parent"
|
||||||
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/bridge_you_send"
|
android:gravity="center_vertical"
|
||||||
android:textColor="@color/text_secondary" />
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bridgeFromLabel"
|
||||||
|
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/bridge_you_send"
|
||||||
|
android:textColor="@color/text_secondary" />
|
||||||
|
|
||||||
|
<io.novafoundation.nova.feature_wallet_api.presentation.view.amount.MaxAmountView
|
||||||
|
android:id="@+id/bridgeFromMaxAmount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<io.novafoundation.nova.feature_assets.presentation.bridge.view.BridgeAssetInputView
|
<io.novafoundation.nova.feature_assets.presentation.bridge.view.BridgeAssetInputView
|
||||||
android:id="@+id/bridgeFromCard"
|
android:id="@+id/bridgeFromCard"
|
||||||
|
|||||||
@@ -110,4 +110,15 @@
|
|||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bridgeAssetInputError"
|
||||||
|
style="@style/TextAppearance.NovaFoundation.Regular.Caption1"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textColor="@color/text_negative"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/bridgeAssetInputContainer" />
|
||||||
|
|
||||||
</merge>
|
</merge>
|
||||||
|
|||||||
Reference in New Issue
Block a user