From 398f2fc996948691724e6410a6f0f494d9e9e204 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Mon, 13 Jul 2026 09:28:30 -0700 Subject: [PATCH] Redesign Bridge screen UI to match Swap's modern card layout Replaces the segmented pair/direction buttons and plain-text amount sections with token-icon cards (BridgeAssetInputView), a one-tap flip button, and a bottom-sheet pair picker (BridgePairListBottomSheet). BridgeViewModel gains only additive presentation data (fromCard/toCard/ pairOptions, derived from the existing pair/direction state and the same chainId/assetId mapping swapClicked() already used) - all bridge business logic (rate, fee, minimum, liquidity warnings, submission) is unchanged. --- common/src/main/res/values/strings.xml | 2 + .../presentation/bridge/BridgeFragment.kt | 119 +++------ .../bridge/BridgePairListBottomSheet.kt | 68 ++++++ .../presentation/bridge/BridgeViewModel.kt | 78 ++++++ .../bridge/view/BridgeAssetInputView.kt | 63 +++++ .../src/main/res/layout/fragment_bridge.xml | 231 +++--------------- .../main/res/layout/item_bridge_pair_list.xml | 29 +++ .../res/layout/view_bridge_asset_input.xml | 113 +++++++++ 8 files changed, 416 insertions(+), 287 deletions(-) create mode 100644 feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgePairListBottomSheet.kt create mode 100644 feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/view/BridgeAssetInputView.kt create mode 100644 feature-assets/src/main/res/layout/item_bridge_pair_list.xml create mode 100644 feature-assets/src/main/res/layout/view_bridge_asset_input.xml diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index f614b976..f0644848 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -355,6 +355,8 @@ DOT ↔ HEZ Bridge + HEZ ⇄ DOT + USDT ⇄ USDT.p You send You receive (estimated) Exchange rate 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 a694cbcc..0c883260 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 @@ -2,11 +2,11 @@ package io.novafoundation.nova.feature_assets.presentation.bridge import android.text.Editable import android.text.TextWatcher -import android.view.View import io.novafoundation.nova.common.base.BaseFragment import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.AlertView import io.novafoundation.nova.common.view.setState -import io.novafoundation.nova.feature_assets.R import io.novafoundation.nova.feature_assets.databinding.FragmentBridgeBinding import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent @@ -18,26 +18,29 @@ class BridgeFragment : BaseFragment() { override fun initViews() { binder.bridgeToolbar.setHomeButtonListener { viewModel.backClicked() } - // Pair selector - binder.bridgePairDotHez.setOnClickListener { - viewModel.setPair(BridgePair.DOT_HEZ) + binder.bridgeToCard.setEditable(false) + + // Tapping the "from" card opens the pair picker - replaces the old segmented pair buttons + binder.bridgeFromCard.setCardClickListener { + val options = viewModel.pairOptions.value.orEmpty() + if (options.isNotEmpty()) { + BridgePairListBottomSheet(requireContext(), options) { selected -> + viewModel.setPair(selected.pair) + }.show() + } } - binder.bridgePairUsdt.setOnClickListener { - viewModel.setPair(BridgePair.USDT) - } - - // Direction toggle - binder.bridgeDirectionLeft.setOnClickListener { - viewModel.setDirectionLeft() - } - - binder.bridgeDirectionRight.setOnClickListener { - viewModel.setDirectionRight() + // One-tap direction flip - replaces the old segmented direction buttons + binder.bridgeFlipButton.setOnClickListener { + when (viewModel.direction.value) { + BridgeDirection.DOT_TO_HEZ, BridgeDirection.USDT_TO_WUSDT -> viewModel.setDirectionRight() + BridgeDirection.HEZ_TO_DOT, BridgeDirection.WUSDT_TO_USDT -> viewModel.setDirectionLeft() + null -> Unit + } } // Amount input - binder.bridgeFromAmount.addTextChangedListener(object : TextWatcher { + binder.bridgeFromCard.amountInput.addTextChangedListener(object : TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun afterTextChanged(s: Editable?) { @@ -63,16 +66,16 @@ class BridgeFragment : BaseFragment() { } override fun subscribe(viewModel: BridgeViewModel) { - viewModel.pair.observe { pair -> - updatePairUI(pair) + viewModel.fromCard.observe { model -> + binder.bridgeFromCard.setModel(model) } - viewModel.direction.observe { direction -> - updateDirectionUI(direction) + viewModel.toCard.observe { model -> + binder.bridgeToCard.setModel(model) } viewModel.outputAmount.observe { output -> - binder.bridgeToAmount.text = output + binder.bridgeToCard.setAmountText(output) } viewModel.exchangeRateText.observe { rate -> @@ -88,80 +91,18 @@ class BridgeFragment : BaseFragment() { } viewModel.showWarning.observe { show -> - binder.bridgeHezToDotWarning.visibility = if (show) View.VISIBLE else View.GONE + binder.bridgeWarningAlert.setVisible(show) } viewModel.warningBlocked.observe { blocked -> - if (blocked) { - binder.bridgeHezToDotWarning.setBackgroundColor(resources.getColor(R.color.error_block_background, null)) - } else { - binder.bridgeHezToDotWarning.setBackgroundColor(resources.getColor(R.color.warning_block_background, null)) - } + binder.bridgeWarningAlert.setStylePreset( + if (blocked) AlertView.StylePreset.ERROR else AlertView.StylePreset.WARNING + ) } viewModel.warningText.observe { text -> if (text.isNotEmpty()) { - binder.bridgeHezToDotWarning.text = text - } - } - } - - private fun updatePairUI(pair: BridgePair) { - when (pair) { - BridgePair.DOT_HEZ -> { - binder.bridgePairDotHez.setBackgroundResource(R.drawable.bg_button_primary) - binder.bridgePairDotHez.setTextColor(resources.getColor(R.color.text_primary, null)) - binder.bridgePairUsdt.background = null - binder.bridgePairUsdt.setTextColor(resources.getColor(R.color.text_secondary, null)) - } - BridgePair.USDT -> { - binder.bridgePairUsdt.setBackgroundResource(R.drawable.bg_button_primary) - binder.bridgePairUsdt.setTextColor(resources.getColor(R.color.text_primary, null)) - binder.bridgePairDotHez.background = null - binder.bridgePairDotHez.setTextColor(resources.getColor(R.color.text_secondary, null)) - } - } - } - - private fun updateDirectionUI(direction: BridgeDirection) { - val isLeft = direction == BridgeDirection.DOT_TO_HEZ || direction == BridgeDirection.USDT_TO_WUSDT - - if (isLeft) { - binder.bridgeDirectionLeft.setBackgroundResource(R.drawable.bg_button_primary) - binder.bridgeDirectionLeft.setTextColor(resources.getColor(R.color.text_primary, null)) - binder.bridgeDirectionRight.background = null - binder.bridgeDirectionRight.setTextColor(resources.getColor(R.color.text_secondary, null)) - } else { - binder.bridgeDirectionRight.setBackgroundResource(R.drawable.bg_button_primary) - binder.bridgeDirectionRight.setTextColor(resources.getColor(R.color.text_primary, null)) - binder.bridgeDirectionLeft.background = null - binder.bridgeDirectionLeft.setTextColor(resources.getColor(R.color.text_secondary, null)) - } - - when (direction) { - BridgeDirection.DOT_TO_HEZ -> { - binder.bridgeDirectionLeft.text = "DOT → HEZ" - binder.bridgeDirectionRight.text = "HEZ → DOT" - binder.bridgeFromToken.text = "DOT" - binder.bridgeToToken.text = "HEZ" - } - BridgeDirection.HEZ_TO_DOT -> { - binder.bridgeDirectionLeft.text = "DOT → HEZ" - binder.bridgeDirectionRight.text = "HEZ → DOT" - binder.bridgeFromToken.text = "HEZ" - binder.bridgeToToken.text = "DOT" - } - BridgeDirection.USDT_TO_WUSDT -> { - binder.bridgeDirectionLeft.text = "USDT(Pol) → USDT(Pez)" - binder.bridgeDirectionRight.text = "USDT(Pez) → USDT(Pol)" - binder.bridgeFromToken.text = "USDT" - binder.bridgeToToken.text = "USDT" - } - BridgeDirection.WUSDT_TO_USDT -> { - binder.bridgeDirectionLeft.text = "USDT(Pol) → USDT(Pez)" - binder.bridgeDirectionRight.text = "USDT(Pez) → USDT(Pol)" - binder.bridgeFromToken.text = "USDT" - binder.bridgeToToken.text = "USDT" + binder.bridgeWarningAlert.setMessage(text) } } } diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgePairListBottomSheet.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgePairListBottomSheet.kt new file mode 100644 index 00000000..189f3cda --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgePairListBottomSheet.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_assets.presentation.bridge + +import android.content.Context +import android.os.Bundle +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.images.setIconOrMakeGone +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListSheetAdapter +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.HolderCreator +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.ItemBridgePairListBinding + +data class BridgePairUi( + val pair: BridgePair, + val icon: Icon, + val title: String +) + +class BridgePairListBottomSheet( + context: Context, + data: List, + onClicked: (BridgePairUi) -> Unit +) : DynamicListBottomSheet( + context, + Payload(data), + BridgePairDiffCallback, + onClicked = { _, item -> onClicked(item) } +) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.bridge_title) + } + + override fun holderCreator(): HolderCreator = { + BridgePairListHolder(ItemBridgePairListBinding.inflate(it.inflater(), it, false)) + } +} + +class BridgePairListHolder( + private val binder: ItemBridgePairListBinding +) : DynamicListSheetAdapter.Holder(binder.root) { + + private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { + FeatureUtils.getCommonApi(binder.root.context).imageLoader() + } + + override fun bind(item: BridgePairUi, isSelected: Boolean, handler: DynamicListSheetAdapter.Handler) { + binder.itemBridgePairIcon.setIconOrMakeGone(item.icon, imageLoader) + binder.itemBridgePairTitle.text = item.title + binder.root.setOnClickListener { handler.itemClicked(item) } + } +} + +private object BridgePairDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: BridgePairUi, newItem: BridgePairUi): Boolean { + return oldItem.pair == newItem.pair + } + + override fun areContentsTheSame(oldItem: BridgePairUi, newItem: BridgePairUi): Boolean { + return true + } +} 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 8421c1e0..1aff0826 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 @@ -4,7 +4,9 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import io.novafoundation.nova.common.base.BaseViewModel import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.images.Icon import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.feature_account_api.presenatation.chain.asIconOrFallback import io.novafoundation.nova.feature_assets.R import io.novafoundation.nova.feature_assets.presentation.AssetsRouter import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload @@ -12,6 +14,7 @@ import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload import io.novafoundation.nova.runtime.ext.ChainGeneses import io.novafoundation.nova.runtime.ext.addressOf import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -76,6 +79,15 @@ class BridgeViewModel( private val _warningText = MutableLiveData() val warningText: LiveData = _warningText + private val _fromCard = MutableLiveData() + val fromCard: LiveData = _fromCard + + private val _toCard = MutableLiveData() + val toCard: LiveData = _toCard + + private val _pairOptions = MutableLiveData>(emptyList()) + val pairOptions: LiveData> = _pairOptions + private var currentAmount: Double = 0.0 private var dotToHezRate: Double = FALLBACK_RATE private var isHezToDotActive: Boolean = false @@ -84,6 +96,8 @@ class BridgeViewModel( init { fetchExchangeRate() fetchBridgeStatus() + updateCards() + loadPairOptions() } fun setPair(newPair: BridgePair) { @@ -97,6 +111,7 @@ class BridgeViewModel( updateUI() calculateOutput() updateWarningState() + updateCards() } } @@ -111,6 +126,7 @@ class BridgeViewModel( updateUI() calculateOutput() updateWarningState() + updateCards() } } @@ -125,6 +141,7 @@ class BridgeViewModel( updateUI() calculateOutput() updateWarningState() + updateCards() } } @@ -322,4 +339,65 @@ class BridgeViewModel( fun refreshBridgeStatus() { fetchBridgeStatus() } + + private fun updateCards() { + val dir = _direction.value ?: return + + // Same chainId/assetId mapping already used by swapClicked() to resolve the origin side - + // mirrored here (plus its destination counterpart) purely to display logos/names, no new business rule. + val originChainId = when (dir) { + BridgeDirection.DOT_TO_HEZ, BridgeDirection.USDT_TO_WUSDT -> POLKADOT_ASSET_HUB_ID + BridgeDirection.HEZ_TO_DOT, BridgeDirection.WUSDT_TO_USDT -> PEZKUWI_ASSET_HUB_ID + } + val destChainId = when (dir) { + BridgeDirection.DOT_TO_HEZ, BridgeDirection.USDT_TO_WUSDT -> PEZKUWI_ASSET_HUB_ID + BridgeDirection.HEZ_TO_DOT, BridgeDirection.WUSDT_TO_USDT -> POLKADOT_ASSET_HUB_ID + } + val originAssetId = when (dir) { + BridgeDirection.DOT_TO_HEZ, BridgeDirection.HEZ_TO_DOT -> UTILITY_ASSET_ID + BridgeDirection.USDT_TO_WUSDT -> POLKADOT_USDT_ASSET_ID + BridgeDirection.WUSDT_TO_USDT -> PEZKUWI_USDT_ASSET_ID + } + val destAssetId = when (dir) { + BridgeDirection.DOT_TO_HEZ, BridgeDirection.HEZ_TO_DOT -> UTILITY_ASSET_ID + BridgeDirection.USDT_TO_WUSDT -> PEZKUWI_USDT_ASSET_ID + BridgeDirection.WUSDT_TO_USDT -> POLKADOT_USDT_ASSET_ID + } + + launch { + _fromCard.value = cardUiFor(originChainId, originAssetId) + _toCard.value = cardUiFor(destChainId, destAssetId) + } + } + + private fun loadPairOptions() { + launch { + val dotHezIcon = cardUiFor(POLKADOT_ASSET_HUB_ID, UTILITY_ASSET_ID).assetIcon + val usdtIcon = cardUiFor(POLKADOT_ASSET_HUB_ID, POLKADOT_USDT_ASSET_ID).assetIcon + + _pairOptions.value = listOf( + BridgePairUi(BridgePair.DOT_HEZ, dotHezIcon, resourceManager.getString(R.string.bridge_pair_dot_hez)), + BridgePairUi(BridgePair.USDT, usdtIcon, resourceManager.getString(R.string.bridge_pair_usdt)) + ) + } + } + + private suspend fun cardUiFor(chainId: String, assetId: Int): BridgeAssetCardUi { + val chain = chainRegistry.getChain(chainId) + val asset = chain.assetsById.getValue(assetId) + + return BridgeAssetCardUi( + assetIcon = asset.icon.asIconOrFallback(), + chainIconUrl = chain.icon, + symbol = asset.symbol.value, + chainName = chain.name + ) + } } + +data class BridgeAssetCardUi( + val assetIcon: Icon, + val chainIconUrl: String?, + val symbol: String, + val chainName: String +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/view/BridgeAssetInputView.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/view/BridgeAssetInputView.kt new file mode 100644 index 00000000..b22ec0a1 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/view/BridgeAssetInputView.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_assets.presentation.bridge.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.EditText +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import coil.ImageLoader +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.images.asUrlIcon +import io.novafoundation.nova.common.utils.images.setIconOrMakeGone +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.shape.getInputBackground +import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon +import io.novafoundation.nova.feature_assets.databinding.ViewBridgeAssetInputBinding +import io.novafoundation.nova.feature_assets.presentation.bridge.BridgeAssetCardUi + +class BridgeAssetInputView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), + WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewBridgeAssetInputBinding.inflate(inflater(), this) + + val amountInput: EditText + get() = binder.bridgeAssetInputField + + private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { + FeatureUtils.getCommonApi(context).imageLoader() + } + + init { + binder.bridgeAssetInputContainer.background = context.getInputBackground() + } + + fun setCardClickListener(listener: OnClickListener) { + binder.bridgeAssetInputContainer.setOnClickListener(listener) + binder.bridgeAssetInputChevron.isVisible = true + } + + fun setEditable(editable: Boolean) { + amountInput.isFocusable = editable + amountInput.isFocusableInTouchMode = editable + amountInput.isClickable = editable + amountInput.isCursorVisible = editable + } + + fun setAmountText(text: CharSequence) { + if (amountInput.text?.toString() != text.toString()) { + amountInput.setText(text) + } + } + + fun setModel(model: BridgeAssetCardUi) { + binder.bridgeAssetInputImage.setTokenIcon(model.assetIcon, imageLoader) + binder.bridgeAssetInputToken.text = model.symbol + binder.bridgeAssetInputSubtitle.text = model.chainName + binder.bridgeAssetInputSubtitleImage.setIconOrMakeGone(model.chainIconUrl?.asUrlIcon(), imageLoader) + } +} diff --git a/feature-assets/src/main/res/layout/fragment_bridge.xml b/feature-assets/src/main/res/layout/fragment_bridge.xml index 4bf845b2..21a95857 100644 --- a/feature-assets/src/main/res/layout/fragment_bridge.xml +++ b/feature-assets/src/main/res/layout/fragment_bridge.xml @@ -25,212 +25,51 @@ android:orientation="vertical" android:padding="16dp"> - - + + + android:layout_marginTop="8dp" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:scaleType="centerInside" + android:src="@drawable/ic_flip_swap" /> - - + + - - - - - - - - - - - - - - - - + android:layout_marginTop="8dp" /> @@ -317,18 +156,14 @@ - - + + android:visibility="gone" + app:alertMode="warning" /> diff --git a/feature-assets/src/main/res/layout/item_bridge_pair_list.xml b/feature-assets/src/main/res/layout/item_bridge_pair_list.xml new file mode 100644 index 00000000..8c5f6b3b --- /dev/null +++ b/feature-assets/src/main/res/layout/item_bridge_pair_list.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/feature-assets/src/main/res/layout/view_bridge_asset_input.xml b/feature-assets/src/main/res/layout/view_bridge_asset_input.xml new file mode 100644 index 00000000..6fd17556 --- /dev/null +++ b/feature-assets/src/main/res/layout/view_bridge_asset_input.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + +