From 73cede8d094a91d1300fc2feec1e55edf52abd71 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 18 Oct 2026 17:53:18 -0600 Subject: [PATCH 1/7] fix: stabilize usdt wallet and payment layouts --- .../PaymentRequestAmountLocaleTest.kt | 53 ++++++++++++++++ .../CreatePaymentRequestScreen.kt | 61 +++++++++++-------- .../wallets/receive/ReceiveQrScreen.kt | 10 ++- .../wallets/usdt/UsdtDepositReceiveScreen.kt | 4 ++ .../screens/wallets/usdt/UsdtWalletScreen.kt | 2 +- changelog.d/next/1565.fixed.md | 1 + journeys/paykit-amount-layout.xml | 11 ++++ .../contact-request-or-pay.xml | 2 +- journeys/usdt-wallet-receive-layout.xml | 10 +++ 9 files changed, 122 insertions(+), 32 deletions(-) create mode 100644 changelog.d/next/1565.fixed.md create mode 100644 journeys/paykit-amount-layout.xml create mode 100644 journeys/usdt-wallet-receive-layout.xml diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt index 5a54a6b2a3..29273bcf05 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt @@ -2,7 +2,11 @@ package to.bitkit.ui.screens.paymentrequests +import androidx.compose.foundation.layout.requiredSize import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled @@ -10,6 +14,8 @@ import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.persistentListOf import org.junit.After import org.junit.Before @@ -24,6 +30,7 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.viewmodels.AmountInputViewModel import java.util.Locale import kotlin.test.assertEquals +import kotlin.test.assertTrue import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -80,4 +87,50 @@ class PaymentRequestAmountLocaleTest { assertEquals(PaykitAmount(PaykitAsset.USD, 15_050uL), submitted?.amount) } + + @Test + fun amountActionsStayAlignedAndReachableOnCompactScreens() { + val configuration = mutableStateOf(Triple(320, 1f, PaykitAsset.USD)) + val amountInput = AmountInputViewModel(AmountInputHandler.stub()) + composeTestRule.setContent { + val (width, scale, asset) = configuration.value + CompositionLocalProvider(LocalDensity provides Density(2f, scale), LocalInspectionMode provides true) { + AppThemeSurface { + PaymentRequestAmountContent( + amountInputViewModel = amountInput, + initialDraft = PaykitPaymentRequestDraft( + amount = PaykitAmount(asset, 150uL), + note = "", + expiresAt = Instant.fromEpochSeconds(2_000_000_000), + ), + contact = null, + onBack = {}, + onContinue = {}, + modifier = Modifier.requiredSize(width.dp, 640.dp) + ) + } + } + } + for (width in listOf(320, 375)) { + for (scale in listOf(1f, 1.6f)) { + for (asset in listOf(PaykitAsset.BTC, PaykitAsset.USD)) { + composeTestRule.runOnIdle { configuration.value = Triple(width, scale, asset) } + composeTestRule.waitForIdle() + val amount = composeTestRule.onNodeWithTag("PaymentRequestAmountField").fetchSemanticsNode().boundsInRoot + val actions = composeTestRule.onNodeWithTag("PaymentRequestAmountActions").fetchSemanticsNode().boundsInRoot + val divider = composeTestRule.onNodeWithTag("PaymentRequestAmountDivider").fetchSemanticsNode().boundsInRoot + val lastControl = if (asset == PaykitAsset.BTC) "PaymentRequestAmountUnit" else "PaymentRequestAssetUSD" + val control = composeTestRule.onNodeWithTag(lastControl).fetchSemanticsNode().boundsInRoot + assertEquals(divider.left, amount.left, 1f) + assertEquals(divider.right, control.right, 1f) + assertTrue(actions.top >= amount.bottom && actions.top - amount.bottom <= 34f) + assertTrue(divider.top >= actions.bottom) + listOf("N1", "N0", "NRemove", "PaymentRequestAmountContinue").forEach { + composeTestRule.onNodeWithTag(it).assertIsDisplayed() + } + } + } + } + } + } diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt index eec6be7dbc..6c700b229b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -68,7 +69,6 @@ import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.FillWidth import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadActionButton import to.bitkit.ui.components.NumberPadAmountText @@ -210,32 +210,28 @@ internal fun PaymentRequestAmountContent( ) } VerticalSpacer(16.dp) - PaymentRequestAssetSelector( - asset = asset, - amount = entered, - allowedAssets = allowedAssets, - conversion = conversion, - onConverted = { - asset = it.asset - if (it.asset == PaykitAsset.BTC) { - amountInputViewModel.setSats(it.atomic.toLong(), currencies) - } else { - dollars = if (it.atomic == 0uL) "" else it.value - } - conversionError = false - }, - onUnavailable = { conversionError = true }, - ) - if (conversionError) { - BodyS( - text = stringResource(R.string.wallet__payment_request_rate_unavailable), - color = Colors.White64 + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().testTag("PaymentRequestAmountActions"), + ) { + PaymentRequestAssetSelector( + asset = asset, + amount = entered, + allowedAssets = allowedAssets, + conversion = conversion, + onConverted = { + asset = it.asset + if (it.asset == PaykitAsset.BTC) { + amountInputViewModel.setSats(it.atomic.toLong(), currencies) + } else { + dollars = if (it.atomic == 0uL) "" else it.value + } + conversionError = false + }, + onUnavailable = { conversionError = true }, ) - } - FillHeight(min = 12.dp) - if (asset == PaykitAsset.BTC) { - Row(modifier = Modifier.fillMaxWidth()) { - FillWidth() + if (asset == PaykitAsset.BTC) { UnitButton( onClick = { amountInputViewModel.switchUnit(currencies) }, color = Colors.Brand, @@ -243,8 +239,18 @@ internal fun PaymentRequestAmountContent( ) } } + if (conversionError) { + BodyS( + text = stringResource(R.string.wallet__payment_request_rate_unavailable), + color = Colors.White64 + ) + } VerticalSpacer(16.dp) - HorizontalDivider(color = Colors.White10) + HorizontalDivider( + color = Colors.White10, + modifier = Modifier.testTag("PaymentRequestAmountDivider"), + ) + FillHeight(min = 16.dp) if (asset == PaykitAsset.BTC) { NumberPad( viewModel = amountInputViewModel, @@ -265,6 +271,7 @@ internal fun PaymentRequestAmountContent( modifier = Modifier.testTag("PaymentRequestNumberPad") ) } + FillHeight(min = 24.dp) PrimaryButton( text = stringResource(R.string.common__continue), enabled = (entered?.atomic ?: 0uL) > 0uL, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 366bc70746..0f5c88f04d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -307,6 +307,10 @@ fun ReceiveQrScreen( onDispose { onUsdtSelected(false) } } + val tabLabels = remember(hardwareTabLabel) { + hardwareTabLabel?.let { persistentMapOf(ReceiveTab.HARDWARE to it) } ?: persistentMapOf() + } + if (selectedTab == ReceiveTab.USDT) { Column( modifier = modifier.fillMaxSize().background( @@ -316,11 +320,13 @@ fun ReceiveQrScreen( UsdtDepositReceiveScreen( viewModel = hiltViewModel(), tabs = visibleTabs, + tabLabels = tabLabels, onSelectTab = ::selectReceiveTab, onBack = onClose, onBlockingChange = onBlockingChange, onContacts = onClickPaymentRequestContacts.takeIf { showPaymentRequestContacts }, ) + VerticalSpacer(16.dp) } return } @@ -359,9 +365,7 @@ fun ReceiveQrScreen( CustomTabRowWithSpacing( tabs = visibleTabs, currentTabIndex = visibleTabs.indexOf(selectedTab), - labelOverrides = remember(hardwareTabLabel) { - hardwareTabLabel?.let { persistentMapOf(ReceiveTab.HARDWARE to it) } ?: persistentMapOf() - }, + labelOverrides = tabLabels, onTabChange = { tab -> haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) val newIndex = visibleTabs.indexOf(tab) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositReceiveScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositReceiveScreen.kt index 77d24e8717..7e631ec31b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositReceiveScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositReceiveScreen.kt @@ -34,6 +34,8 @@ import com.synonym.bitkitcore.UsdtDepositDetail import com.synonym.bitkitcore.UsdtDepositNetwork import com.synonym.bitkitcore.usdtFormatAmount import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf import to.bitkit.R import to.bitkit.env.Env import to.bitkit.models.PaykitAmount @@ -64,6 +66,7 @@ internal fun ColumnScope.UsdtDepositReceiveScreen( viewModel: UsdtViewModel, tabs: ImmutableList, onSelectTab: (ReceiveTab) -> Unit, + tabLabels: ImmutableMap = persistentMapOf(), onBack: () -> Unit, onBlockingChange: (Boolean) -> Unit, onContacts: (() -> Unit)? = null, @@ -128,6 +131,7 @@ internal fun ColumnScope.UsdtDepositReceiveScreen( CustomTabRowWithSpacing( tabs = tabs, currentTabIndex = tabs.indexOf(ReceiveTab.USDT), + labelOverrides = tabLabels, onTabChange = onSelectTab ) VerticalSpacer(16.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt index 75769b592c..3e546d104e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt @@ -329,7 +329,7 @@ private fun UsdtWalletContent( val allowSwipe by settings?.enableSwipeToHideBalance?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(false) } - Box(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize().background(Colors.Black)) { UsdtCoinIllustration( modifier = Modifier.align(Alignment.TopEnd) .offset(x = 81.dp, y = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 4.dp) diff --git a/changelog.d/next/1565.fixed.md b/changelog.d/next/1565.fixed.md new file mode 100644 index 0000000000..cd94607dbb --- /dev/null +++ b/changelog.d/next/1565.fixed.md @@ -0,0 +1 @@ +Keep USDT wallet transitions and Receive controls consistent, and improve payment request and subscription amount layouts. diff --git a/journeys/paykit-amount-layout.xml b/journeys/paykit-amount-layout.xml new file mode 100644 index 0000000000..6ef99383fe --- /dev/null +++ b/journeys/paykit-amount-layout.xml @@ -0,0 +1,11 @@ + + Use an onboarded wallet with USDT enabled, an existing test contact and a current BTC/USD rate. Verify request denomination layout without sending or publishing a request. + + Open Payment Requests and create a request for the test contact. Open Amount. + Select USD and enter 1.50. Verify the amount is leading-aligned near the header, the BTC/USD controls are trailing-aligned directly below it, and a divider separates them from the keypad. + Verify Continue is visible or reachable by scrolling at enlarged text sizes. Continue to details and verify the request denomination and amount remain 1.50 USD. Cancel without sending. + Open Subscriptions and start creating a subscription for the same contact. Open Amount and repeat the layout check in BTC and USD. Verify the controls do not overlap the keypad or Continue. + Return to subscription details and verify accepted payment methods remain either USDT or Bitcoin methods, never both groups together. Cancel without sending. + Repeat the amount checks at a compact screen width and enlarged system text. Verify every control remains readable and reachable; controls may stack or wrap and the screen may scroll. + + diff --git a/journeys/payment-requests/contact-request-or-pay.xml b/journeys/payment-requests/contact-request-or-pay.xml index 1f15998980..be5bc9e077 100644 --- a/journeys/payment-requests/contact-request-or-pay.xml +++ b/journeys/payment-requests/contact-request-or-pay.xml @@ -16,6 +16,6 @@ Verify the Request or Pay sheet (testTag "RequestOrPaySheet") appears Tap Request on the sheet Verify the Payment Request amount screen (testTag "PaymentRequestAmount") opens - Open Send and choose Contact. Tap a saved contact and verify its row shows loading feedback while payment details resolve; other contacts cannot be selected meanwhile. When resolution finishes, verify the amount screen opens. Cancel without paying. Reopen Send, choose Contact and select the prepared contact with no payment endpoints. Verify the error is shown and contact selection becomes available for retry. Select the original payable contact and verify its amount screen opens, then cancel without paying. + Open Send and choose Contact. Tap a saved contact and verify only its row shows loading while payment details resolve; all contact rows are disabled meanwhile. Try selecting another contact and verify no second payment flow opens. When resolution finishes, verify the amount screen opens. Cancel without paying. Reopen Send, choose Contact and select the prepared contact with no payment endpoints. Verify the error is shown and contact selection becomes available for retry. Select the original payable contact and verify its amount screen opens, then cancel without paying. diff --git a/journeys/usdt-wallet-receive-layout.xml b/journeys/usdt-wallet-receive-layout.xml new file mode 100644 index 0000000000..8c3f62129b --- /dev/null +++ b/journeys/usdt-wallet-receive-layout.xml @@ -0,0 +1,10 @@ + + Use an onboarded wallet with USDT enabled. For the hardware label check, use a paired hardware test wallet. No funds or payment submission are required. + + Open Savings from Home and return. Open USDT from Home and return. Verify both wallets use the same slide transition without showing the Home contents through the destination. + Open Receive. Switch repeatedly between Savings, Spending and USDT. Verify Show Details stays at the same bottom position and no footer overlaps the system navigation area. + On USDT, verify Network: Arbitrum One is visible. Open network selection and return without creating a deposit. Verify the network remains visible and Show Details still opens details. + With a paired hardware wallet, switch between its receive tab and USDT. Verify the hardware tab retains its vendor name on every tab instead of changing to Hardware. + Repeat on a compact screen and with enlarged system text. Verify the selected network and bottom controls are visible or reachable and the hardware label remains consistent. + + From 53ad954145bd8665f318e13ba90cb0f949fd7a5d Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 18 Oct 2026 19:23:44 -0600 Subject: [PATCH 2/7] Keep amount keypad and action visible on short screens --- .../PaymentRequestAmountLocaleTest.kt | 13 +- .../CreatePaymentRequestScreen.kt | 141 +++++++++--------- 2 files changed, 80 insertions(+), 74 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt index 29273bcf05..4ddfd0ebe4 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt @@ -91,6 +91,7 @@ class PaymentRequestAmountLocaleTest { @Test fun amountActionsStayAlignedAndReachableOnCompactScreens() { val configuration = mutableStateOf(Triple(320, 1f, PaykitAsset.USD)) + val height = mutableStateOf(520) val amountInput = AmountInputViewModel(AmountInputHandler.stub()) composeTestRule.setContent { val (width, scale, asset) = configuration.value @@ -106,15 +107,18 @@ class PaymentRequestAmountLocaleTest { contact = null, onBack = {}, onContinue = {}, - modifier = Modifier.requiredSize(width.dp, 640.dp) + modifier = Modifier.requiredSize(width.dp, height.value.dp) ) } } } - for (width in listOf(320, 375)) { + for ((width, screenHeight) in listOf(320 to 520, 375 to 520, 320 to 640, 375 to 640)) { for (scale in listOf(1f, 1.6f)) { for (asset in listOf(PaykitAsset.BTC, PaykitAsset.USD)) { - composeTestRule.runOnIdle { configuration.value = Triple(width, scale, asset) } + composeTestRule.runOnIdle { + height.value = screenHeight + configuration.value = Triple(width, scale, asset) + } composeTestRule.waitForIdle() val amount = composeTestRule.onNodeWithTag("PaymentRequestAmountField").fetchSemanticsNode().boundsInRoot val actions = composeTestRule.onNodeWithTag("PaymentRequestAmountActions").fetchSemanticsNode().boundsInRoot @@ -125,6 +129,9 @@ class PaymentRequestAmountLocaleTest { assertEquals(divider.right, control.right, 1f) assertTrue(actions.top >= amount.bottom && actions.top - amount.bottom <= 34f) assertTrue(divider.top >= actions.bottom) + val continueButton = composeTestRule.onNodeWithTag("PaymentRequestAmountContinue") + .fetchSemanticsNode().boundsInRoot + assertTrue(continueButton.height >= 112f, "Continue must be fully visible") listOf("N1", "N0", "NRemove", "PaymentRequestAmountContinue").forEach { composeTestRule.onNodeWithTag(it).assertIsDisplayed() } diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt index 6c700b229b..509d2591da 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt @@ -7,14 +7,16 @@ import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding @@ -68,7 +70,6 @@ import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.Display -import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadActionButton import to.bitkit.ui.components.NumberPadAmountText @@ -192,71 +193,71 @@ internal fun PaymentRequestAmountContent( } }, ) - BoxWithConstraints(modifier = Modifier.weight(1f)) { - val availableHeight = this.maxHeight - - Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) { - VerticalSpacer(16.dp) + Column(modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 16.dp)) { + VerticalSpacer(16.dp) + if (asset == PaykitAsset.BTC) { + NumberPadTextField( + viewModel = amountInputViewModel, + modifier = Modifier.fillMaxWidth().testTag("PaymentRequestAmountField") + ) + } else { + NumberPadAmountText( + value = dollars.ifEmpty { "0" }.replace('.', decimalSeparator), + symbol = "$", + modifier = Modifier.testTag("PaymentRequestAmountField") + ) + } + VerticalSpacer(16.dp) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().testTag("PaymentRequestAmountActions"), + ) { + PaymentRequestAssetSelector( + asset = asset, + amount = entered, + allowedAssets = allowedAssets, + conversion = conversion, + onConverted = { + asset = it.asset + if (it.asset == PaykitAsset.BTC) { + amountInputViewModel.setSats(it.atomic.toLong(), currencies) + } else { + dollars = if (it.atomic == 0uL) "" else it.value + } + conversionError = false + }, + onUnavailable = { conversionError = true }, + ) if (asset == PaykitAsset.BTC) { - NumberPadTextField( - viewModel = amountInputViewModel, - modifier = Modifier.fillMaxWidth().testTag("PaymentRequestAmountField") - ) - } else { - NumberPadAmountText( - value = dollars.ifEmpty { "0" }.replace('.', decimalSeparator), - symbol = "$", - modifier = Modifier.testTag("PaymentRequestAmountField") - ) - } - VerticalSpacer(16.dp) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth().testTag("PaymentRequestAmountActions"), - ) { - PaymentRequestAssetSelector( - asset = asset, - amount = entered, - allowedAssets = allowedAssets, - conversion = conversion, - onConverted = { - asset = it.asset - if (it.asset == PaykitAsset.BTC) { - amountInputViewModel.setSats(it.atomic.toLong(), currencies) - } else { - dollars = if (it.atomic == 0uL) "" else it.value - } - conversionError = false - }, - onUnavailable = { conversionError = true }, + UnitButton( + onClick = { amountInputViewModel.switchUnit(currencies) }, + color = Colors.Brand, + modifier = Modifier.testTag("PaymentRequestAmountUnit") ) - if (asset == PaykitAsset.BTC) { - UnitButton( - onClick = { amountInputViewModel.switchUnit(currencies) }, - color = Colors.Brand, - modifier = Modifier.testTag("PaymentRequestAmountUnit") - ) - } } - if (conversionError) { - BodyS( - text = stringResource(R.string.wallet__payment_request_rate_unavailable), - color = Colors.White64 - ) - } - VerticalSpacer(16.dp) - HorizontalDivider( - color = Colors.White10, - modifier = Modifier.testTag("PaymentRequestAmountDivider"), + } + if (conversionError) { + BodyS( + text = stringResource(R.string.wallet__payment_request_rate_unavailable), + color = Colors.White64 ) - FillHeight(min = 16.dp) + } + VerticalSpacer(16.dp) + HorizontalDivider( + color = Colors.White10, + modifier = Modifier.testTag("PaymentRequestAmountDivider"), + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(1f).fillMaxWidth().padding(top = 16.dp, bottom = 24.dp), + ) { + val padModifier = Modifier.heightIn(max = 300.dp).fillMaxHeight().testTag("PaymentRequestNumberPad") if (asset == PaykitAsset.BTC) { NumberPad( viewModel = amountInputViewModel, currencies = currencies, - availableHeight = availableHeight, - modifier = Modifier.testTag("PaymentRequestNumberPad") + modifier = padModifier ) } else { NumberPad( @@ -266,22 +267,20 @@ internal fun PaymentRequestAmountContent( ) }, type = NumberPadType.DECIMAL, - availableHeight = availableHeight, decimalSeparator = decimalSeparator.toString(), - modifier = Modifier.testTag("PaymentRequestNumberPad") + modifier = padModifier ) } - FillHeight(min = 24.dp) - PrimaryButton( - text = stringResource(R.string.common__continue), - enabled = (entered?.atomic ?: 0uL) > 0uL, - onClick = { - entered?.let { onContinue(initialDraft.copy(amount = it)) } - }, - modifier = Modifier.testTag("PaymentRequestAmountContinue"), - ) - VerticalSpacer(16.dp) } + PrimaryButton( + text = stringResource(R.string.common__continue), + enabled = (entered?.atomic ?: 0uL) > 0uL, + onClick = { + entered?.let { onContinue(initialDraft.copy(amount = it)) } + }, + modifier = Modifier.testTag("PaymentRequestAmountContinue"), + ) + VerticalSpacer(16.dp) } } } From 5a3e8f51987788847cc3b45ec588d3d0ff85e851 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 18 Oct 2026 22:08:32 -0600 Subject: [PATCH 3/7] fix: stabilize amount denomination layout --- .../PaymentRequestAmountLocaleTest.kt | 15 +++ .../CreatePaymentRequestScreen.kt | 103 +++++++++++------- journeys/paykit-amount-layout.xml | 1 + 3 files changed, 82 insertions(+), 37 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt index 4ddfd0ebe4..f03517b547 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt @@ -114,6 +114,7 @@ class PaymentRequestAmountLocaleTest { } for ((width, screenHeight) in listOf(320 to 520, 375 to 520, 320 to 640, 375 to 640)) { for (scale in listOf(1f, 1.6f)) { + var bitcoinPositions = emptyList() for (asset in listOf(PaykitAsset.BTC, PaykitAsset.USD)) { composeTestRule.runOnIdle { height.value = screenHeight @@ -129,6 +130,20 @@ class PaymentRequestAmountLocaleTest { assertEquals(divider.right, control.right, 1f) assertTrue(actions.top >= amount.bottom && actions.top - amount.bottom <= 34f) assertTrue(divider.top >= actions.bottom) + val positions = listOf( + actions.top, + divider.top, + composeTestRule.onNodeWithTag("N1").fetchSemanticsNode().boundsInRoot.top, + composeTestRule.onNodeWithTag("N0").fetchSemanticsNode().boundsInRoot.top, + ) + if (asset == PaykitAsset.BTC) { + bitcoinPositions = positions + } else { + composeTestRule.onNodeWithTag("PaymentRequestAmountUnit").assertDoesNotExist() + bitcoinPositions.zip(positions).forEach { (bitcoin, usd) -> + assertEquals(bitcoin, usd, 1f, "Controls must not move when denomination changes") + } + } val continueButton = composeTestRule.onNodeWithTag("PaymentRequestAmountContinue") .fetchSemanticsNode().boundsInRoot assertTrue(continueButton.height >= 112f, "Continue must be fully visible") diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt index 509d2591da..2fa86fe7b4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt @@ -37,10 +37,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -195,46 +197,50 @@ internal fun PaymentRequestAmountContent( ) Column(modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 16.dp)) { VerticalSpacer(16.dp) - if (asset == PaykitAsset.BTC) { - NumberPadTextField( - viewModel = amountInputViewModel, - modifier = Modifier.fillMaxWidth().testTag("PaymentRequestAmountField") - ) - } else { - NumberPadAmountText( - value = dollars.ifEmpty { "0" }.replace('.', decimalSeparator), - symbol = "$", - modifier = Modifier.testTag("PaymentRequestAmountField") - ) + PaymentRequestDenominationLayout(asset, Alignment.Bottom) { displayAsset -> + if (displayAsset == PaykitAsset.BTC) { + NumberPadTextField( + viewModel = amountInputViewModel, + modifier = Modifier.fillMaxWidth().testTag("PaymentRequestAmountField") + ) + } else { + NumberPadAmountText( + value = dollars.ifEmpty { "0" }.replace('.', decimalSeparator), + symbol = "$", + modifier = Modifier.testTag("PaymentRequestAmountField") + ) + } } VerticalSpacer(16.dp) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth().testTag("PaymentRequestAmountActions"), - ) { - PaymentRequestAssetSelector( - asset = asset, - amount = entered, - allowedAssets = allowedAssets, - conversion = conversion, - onConverted = { - asset = it.asset - if (it.asset == PaykitAsset.BTC) { - amountInputViewModel.setSats(it.atomic.toLong(), currencies) - } else { - dollars = if (it.atomic == 0uL) "" else it.value - } - conversionError = false - }, - onUnavailable = { conversionError = true }, - ) - if (asset == PaykitAsset.BTC) { - UnitButton( - onClick = { amountInputViewModel.switchUnit(currencies) }, - color = Colors.Brand, - modifier = Modifier.testTag("PaymentRequestAmountUnit") + PaymentRequestDenominationLayout(asset, Alignment.Top) { displayAsset -> + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().testTag("PaymentRequestAmountActions"), + ) { + PaymentRequestAssetSelector( + asset = asset, + amount = entered, + allowedAssets = allowedAssets, + conversion = conversion, + onConverted = { + asset = it.asset + if (it.asset == PaykitAsset.BTC) { + amountInputViewModel.setSats(it.atomic.toLong(), currencies) + } else { + dollars = if (it.atomic == 0uL) "" else it.value + } + conversionError = false + }, + onUnavailable = { conversionError = true }, ) + if (displayAsset == PaykitAsset.BTC) { + UnitButton( + onClick = { amountInputViewModel.switchUnit(currencies) }, + color = Colors.Brand, + modifier = Modifier.testTag("PaymentRequestAmountUnit") + ) + } } } if (conversionError) { @@ -285,6 +291,29 @@ internal fun PaymentRequestAmountContent( } } +@Composable +private fun PaymentRequestDenominationLayout( + asset: PaykitAsset, + verticalAlignment: Alignment.Vertical, + content: @Composable (PaykitAsset) -> Unit, +) { + Layout(content = { + Box(modifier = if (asset == PaykitAsset.BTC) Modifier else Modifier.clearAndSetSemantics {}) { + content(PaykitAsset.BTC) + } + Box(modifier = if (asset == PaykitAsset.USD) Modifier else Modifier.clearAndSetSemantics {}) { + content(PaykitAsset.USD) + } + }) { measurables, constraints -> + val placeables = measurables.map { it.measure(constraints.copy(minHeight = 0)) } + val height = placeables.maxOf { it.height } + val visible = placeables[if (asset == PaykitAsset.BTC) 0 else 1] + layout(placeables.maxOf { it.width }, height) { + visible.placeRelative(0, verticalAlignment.align(visible.height, height)) + } + } +} + private const val MAX_USD_INPUT_LENGTH = 18 @Composable diff --git a/journeys/paykit-amount-layout.xml b/journeys/paykit-amount-layout.xml index 6ef99383fe..6c0b3d16c1 100644 --- a/journeys/paykit-amount-layout.xml +++ b/journeys/paykit-amount-layout.xml @@ -3,6 +3,7 @@ Open Payment Requests and create a request for the test contact. Open Amount. Select USD and enter 1.50. Verify the amount is leading-aligned near the header, the BTC/USD controls are trailing-aligned directly below it, and a divider separates them from the keypad. + Switch between BTC and USD several times. Verify the amount controls, divider and keypad stay at the same vertical positions, including when the Bitcoin unit button wraps below the denomination buttons at enlarged text sizes. Verify Continue is visible or reachable by scrolling at enlarged text sizes. Continue to details and verify the request denomination and amount remain 1.50 USD. Cancel without sending. Open Subscriptions and start creating a subscription for the same contact. Open Amount and repeat the layout check in BTC and USD. Verify the controls do not overlap the keypad or Continue. Return to subscription details and verify accepted payment methods remain either USDT or Bitcoin methods, never both groups together. Cancel without sending. From 45da736f43c13fb4d95d7c60ca616d00b3e9802e Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 18 Oct 2026 22:18:17 -0600 Subject: [PATCH 4/7] test: cover compact receive action targets --- journeys/usdt-wallet-receive-layout.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/journeys/usdt-wallet-receive-layout.xml b/journeys/usdt-wallet-receive-layout.xml index 8c3f62129b..2d8ba78447 100644 --- a/journeys/usdt-wallet-receive-layout.xml +++ b/journeys/usdt-wallet-receive-layout.xml @@ -5,6 +5,7 @@ Open Receive. Switch repeatedly between Savings, Spending and USDT. Verify Show Details stays at the same bottom position and no footer overlaps the system navigation area. On USDT, verify Network: Arbitrum One is visible. Open network selection and return without creating a deposit. Verify the network remains visible and Show Details still opens details. With a paired hardware wallet, switch between its receive tab and USDT. Verify the hardware tab retains its vendor name on every tab instead of changing to Hardware. + On a compact screen, verify the QR fits above Edit, Copy and Share without the Network button covering them. Tap Copy and verify the plain receiving address reaches the clipboard without opening network selection; scan the displayed QR and verify the payment link and selected network. Repeat on a compact screen and with enlarged system text. Verify the selected network and bottom controls are visible or reachable and the hardware label remains consistent. From 03e1952f780944396814f0234f047f8beea81bce Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 18 Oct 2026 23:15:11 -0600 Subject: [PATCH 5/7] fix: keep compact payment keypads usable --- .../PaymentRequestAmountLocaleTest.kt | 83 ++++++-- .../CreatePaymentRequestScreen.kt | 196 ++++++++++-------- .../wallets/usdt/UsdtActivityDetail.kt | 3 +- .../wallets/usdt/UsdtDepositsScreen.kt | 3 +- journeys/paykit-amount-layout.xml | 2 +- journeys/usdt-wallet-receive-layout.xml | 1 + 6 files changed, 181 insertions(+), 107 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt index f03517b547..4279d0965a 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt @@ -6,14 +6,29 @@ import androidx.compose.foundation.layout.requiredSize import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.persistentListOf @@ -30,6 +45,7 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.viewmodels.AmountInputViewModel import java.util.Locale import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -83,15 +99,15 @@ class PaymentRequestAmountLocaleTest { composeTestRule.onNodeWithTag("N5").performClick() composeTestRule.onNodeWithTag("N0").performClick() composeTestRule.onNodeWithText("150,50", useUnmergedTree = true).assertIsDisplayed() - composeTestRule.onNodeWithTag("PaymentRequestAmountContinue").performClick() + composeTestRule.onNodeWithTag("PaymentRequestAmountContinue").performScrollTo().performClick() assertEquals(PaykitAmount(PaykitAsset.USD, 15_050uL), submitted?.amount) } @Test fun amountActionsStayAlignedAndReachableOnCompactScreens() { - val configuration = mutableStateOf(Triple(320, 1f, PaykitAsset.USD)) - val height = mutableStateOf(520) + val configuration = mutableStateOf(Triple(320, 2f, PaykitAsset.USD)) + val height = mutableStateOf(468) val amountInput = AmountInputViewModel(AmountInputHandler.stub()) composeTestRule.setContent { val (width, scale, asset) = configuration.value @@ -112,8 +128,16 @@ class PaymentRequestAmountLocaleTest { } } } - for ((width, screenHeight) in listOf(320 to 520, 375 to 520, 320 to 640, 375 to 640)) { - for (scale in listOf(1f, 1.6f)) { + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("PaymentRequestAmountField").assertIsDisplayed() + composeTestRule.onRoot().performKeyInput { + pressKey(Key.Backspace) + pressKey(Key.Five) + pressKey(Key.Five) + } + composeTestRule.onNodeWithText("1,55", useUnmergedTree = true).assertIsDisplayed() + for ((width, screenHeight) in listOf(320 to 468, 320 to 520, 360 to 568, 375 to 640)) { + for (scale in listOf(1f, 1.6f, 2f)) { var bitcoinPositions = emptyList() for (asset in listOf(PaykitAsset.BTC, PaykitAsset.USD)) { composeTestRule.runOnIdle { @@ -121,20 +145,23 @@ class PaymentRequestAmountLocaleTest { configuration.value = Triple(width, scale, asset) } composeTestRule.waitForIdle() - val amount = composeTestRule.onNodeWithTag("PaymentRequestAmountField").fetchSemanticsNode().boundsInRoot - val actions = composeTestRule.onNodeWithTag("PaymentRequestAmountActions").fetchSemanticsNode().boundsInRoot - val divider = composeTestRule.onNodeWithTag("PaymentRequestAmountDivider").fetchSemanticsNode().boundsInRoot + composeTestRule.onNode( + hasScrollAction() and hasAnyDescendant(hasTestTag("PaymentRequestAmountField")) + ).performSemanticsAction(SemanticsActions.ScrollBy) { it(0f, -10_000f) } + val amount = composeTestRule.onNodeWithTag("PaymentRequestAmountField").getUnclippedBoundsInRoot() + val actions = composeTestRule.onNodeWithTag("PaymentRequestAmountActions").getUnclippedBoundsInRoot() + val divider = composeTestRule.onNodeWithTag("PaymentRequestAmountDivider").getUnclippedBoundsInRoot() val lastControl = if (asset == PaykitAsset.BTC) "PaymentRequestAmountUnit" else "PaymentRequestAssetUSD" - val control = composeTestRule.onNodeWithTag(lastControl).fetchSemanticsNode().boundsInRoot - assertEquals(divider.left, amount.left, 1f) - assertEquals(divider.right, control.right, 1f) - assertTrue(actions.top >= amount.bottom && actions.top - amount.bottom <= 34f) + val control = composeTestRule.onNodeWithTag(lastControl).getUnclippedBoundsInRoot() + assertEquals(divider.left.value, amount.left.value, 0.5f) + assertEquals(divider.right.value, control.right.value, 0.5f) + assertTrue(actions.top >= amount.bottom && actions.top - amount.bottom <= 17.dp) assertTrue(divider.top >= actions.bottom) val positions = listOf( - actions.top, - divider.top, - composeTestRule.onNodeWithTag("N1").fetchSemanticsNode().boundsInRoot.top, - composeTestRule.onNodeWithTag("N0").fetchSemanticsNode().boundsInRoot.top, + actions.top.value, + divider.top.value, + composeTestRule.onNodeWithTag("N1").getUnclippedBoundsInRoot().top.value, + composeTestRule.onNodeWithTag("N0").getUnclippedBoundsInRoot().top.value, ) if (asset == PaykitAsset.BTC) { bitcoinPositions = positions @@ -144,12 +171,26 @@ class PaymentRequestAmountLocaleTest { assertEquals(bitcoin, usd, 1f, "Controls must not move when denomination changes") } } - val continueButton = composeTestRule.onNodeWithTag("PaymentRequestAmountContinue") - .fetchSemanticsNode().boundsInRoot - assertTrue(continueButton.height >= 112f, "Continue must be fully visible") - listOf("N1", "N0", "NRemove", "PaymentRequestAmountContinue").forEach { - composeTestRule.onNodeWithTag(it).assertIsDisplayed() + val extraKey = if (asset == PaykitAsset.BTC) "N000" else "NDecimal" + for (tag in (0..9).map { "N$it" } + extraKey + "NRemove") { + composeTestRule.onNodeWithTag(tag) + .performScrollTo().assertIsDisplayed().assertHeightIsAtLeast(50.dp) + if (tag != "NRemove") { + val layouts = mutableListOf() + val text = when (tag) { + "NDecimal" -> "," + "N000" -> "000" + else -> tag.removePrefix("N") + } + composeTestRule.onNode( + hasText(text) and hasAnyAncestor(hasTestTag(tag)), useUnmergedTree = true + ).performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) } + assertTrue(layouts.isNotEmpty()) + assertFalse(layouts.single().didOverflowHeight, "$tag must fit vertically at $scale font scale") + } } + composeTestRule.onNodeWithTag("PaymentRequestAmountContinue") + .performScrollTo().assertIsDisplayed().assertHeightIsAtLeast(56.dp) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt index 2fa86fe7b4..5cefa8376b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt @@ -4,15 +4,18 @@ package to.bitkit.ui.screens.paymentrequests import androidx.annotation.StringRes +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -30,6 +33,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -37,6 +41,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -72,6 +77,7 @@ import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadActionButton import to.bitkit.ui.components.NumberPadAmountText @@ -140,6 +146,7 @@ fun PaymentRequestAmountScreen( ) } +@OptIn(ExperimentalFoundationApi::class) @Composable internal fun PaymentRequestAmountContent( modifier: Modifier = Modifier, @@ -161,6 +168,18 @@ internal fun PaymentRequestAmountContent( mutableStateOf(if (initialDraft.amount.asset != PaykitAsset.BTC) initialDraft.amount.value else "") } var conversionError by remember { mutableStateOf(false) } + var isNumberPadFocused by remember { mutableStateOf(false) } + val defaultBringIntoView = LocalBringIntoViewSpec.current + val bringIntoView = remember(defaultBringIntoView) { + object : BringIntoViewSpec { + override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = + if (isNumberPadFocused) { + 0f + } else { + defaultBringIntoView.calculateScrollDistance(offset, size, containerSize) + } + } + } val entered = if (asset == PaykitAsset.BTC) { PaykitAmount(asset, amountState.sats.toULong()) } else { @@ -195,98 +214,109 @@ internal fun PaymentRequestAmountContent( } }, ) - Column(modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 16.dp)) { - VerticalSpacer(16.dp) - PaymentRequestDenominationLayout(asset, Alignment.Bottom) { displayAsset -> - if (displayAsset == PaykitAsset.BTC) { - NumberPadTextField( - viewModel = amountInputViewModel, - modifier = Modifier.fillMaxWidth().testTag("PaymentRequestAmountField") - ) - } else { - NumberPadAmountText( - value = dollars.ifEmpty { "0" }.replace('.', decimalSeparator), - symbol = "$", - modifier = Modifier.testTag("PaymentRequestAmountField") - ) - } - } - VerticalSpacer(16.dp) - PaymentRequestDenominationLayout(asset, Alignment.Top) { displayAsset -> - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth().testTag("PaymentRequestAmountActions"), + CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoView) { + BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight).padding(horizontal = 16.dp) ) { - PaymentRequestAssetSelector( - asset = asset, - amount = entered, - allowedAssets = allowedAssets, - conversion = conversion, - onConverted = { - asset = it.asset - if (it.asset == PaykitAsset.BTC) { - amountInputViewModel.setSats(it.atomic.toLong(), currencies) - } else { - dollars = if (it.atomic == 0uL) "" else it.value + VerticalSpacer(16.dp) + PaymentRequestDenominationLayout(asset, Alignment.Bottom) { displayAsset -> + if (displayAsset == PaykitAsset.BTC) { + NumberPadTextField( + viewModel = amountInputViewModel, + modifier = Modifier.fillMaxWidth().testTag("PaymentRequestAmountField") + ) + } else { + NumberPadAmountText( + value = dollars.ifEmpty { "0" }.replace('.', decimalSeparator), + symbol = "$", + modifier = Modifier.testTag("PaymentRequestAmountField") + ) + } + } + VerticalSpacer(16.dp) + PaymentRequestDenominationLayout(asset, Alignment.Top) { displayAsset -> + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().testTag("PaymentRequestAmountActions"), + ) { + PaymentRequestAssetSelector( + asset = asset, + amount = entered, + allowedAssets = allowedAssets, + conversion = conversion, + onConverted = { + asset = it.asset + if (it.asset == PaykitAsset.BTC) { + amountInputViewModel.setSats(it.atomic.toLong(), currencies) + } else { + dollars = if (it.atomic == 0uL) "" else it.value + } + conversionError = false + }, + onUnavailable = { conversionError = true }, + ) + if (displayAsset == PaykitAsset.BTC) { + UnitButton( + onClick = { amountInputViewModel.switchUnit(currencies) }, + color = Colors.Brand, + modifier = Modifier.testTag("PaymentRequestAmountUnit") + ) } - conversionError = false - }, - onUnavailable = { conversionError = true }, - ) - if (displayAsset == PaykitAsset.BTC) { - UnitButton( - onClick = { amountInputViewModel.switchUnit(currencies) }, - color = Colors.Brand, - modifier = Modifier.testTag("PaymentRequestAmountUnit") + } + } + if (conversionError) { + BodyS( + text = stringResource(R.string.wallet__payment_request_rate_unavailable), + color = Colors.White64 ) } - } - } - if (conversionError) { - BodyS( - text = stringResource(R.string.wallet__payment_request_rate_unavailable), - color = Colors.White64 - ) - } - VerticalSpacer(16.dp) - HorizontalDivider( - color = Colors.White10, - modifier = Modifier.testTag("PaymentRequestAmountDivider"), - ) - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.weight(1f).fillMaxWidth().padding(top = 16.dp, bottom = 24.dp), - ) { - val padModifier = Modifier.heightIn(max = 300.dp).fillMaxHeight().testTag("PaymentRequestNumberPad") - if (asset == PaykitAsset.BTC) { - NumberPad( - viewModel = amountInputViewModel, - currencies = currencies, - modifier = padModifier + VerticalSpacer(16.dp) + HorizontalDivider( + color = Colors.White10, + modifier = Modifier.testTag("PaymentRequestAmountDivider"), ) - } else { - NumberPad( - onPress = { - dollars = NumberPadInputHandler.handleInput( - it, dollars, MAX_USD_INPUT_LENGTH, PaykitAsset.USD.decimals + FillHeight() + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 24.dp), + ) { + val padModifier = Modifier + .onFocusChanged { isNumberPadFocused = it.isFocused } + .testTag("PaymentRequestNumberPad") + if (asset == PaykitAsset.BTC) { + NumberPad( + viewModel = amountInputViewModel, + currencies = currencies, + modifier = padModifier + ) + } else { + NumberPad( + onPress = { + dollars = NumberPadInputHandler.handleInput( + it, dollars, MAX_USD_INPUT_LENGTH, PaykitAsset.USD.decimals + ) + }, + type = NumberPadType.DECIMAL, + decimalSeparator = decimalSeparator.toString(), + modifier = padModifier ) + } + } + FillHeight() + PrimaryButton( + text = stringResource(R.string.common__continue), + enabled = (entered?.atomic ?: 0uL) > 0uL, + onClick = { + entered?.let { onContinue(initialDraft.copy(amount = it)) } }, - type = NumberPadType.DECIMAL, - decimalSeparator = decimalSeparator.toString(), - modifier = padModifier + modifier = Modifier.testTag("PaymentRequestAmountContinue"), ) + VerticalSpacer(16.dp) } } - PrimaryButton( - text = stringResource(R.string.common__continue), - enabled = (entered?.atomic ?: 0uL) > 0uL, - onClick = { - entered?.let { onContinue(initialDraft.copy(amount = it)) } - }, - modifier = Modifier.testTag("PaymentRequestAmountContinue"), - ) - VerticalSpacer(16.dp) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtActivityDetail.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtActivityDetail.kt index e36481de39..86ad7893f3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtActivityDetail.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtActivityDetail.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.wallets.usdt +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -116,7 +117,7 @@ internal fun UsdtActivityDetail( var showDetails by rememberSaveable(transfer.id) { mutableStateOf(false) } val scrollState = rememberScrollState() LaunchedEffect(transfer.id, showDetails) { scrollState.scrollTo(0) } - Column(modifier = Modifier.fillMaxSize().navigationBarsPadding()) { + Column(modifier = Modifier.fillMaxSize().background(Colors.Black).navigationBarsPadding()) { AppTopBar( titleText = stringResource( transfer.detailTitleResource diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositsScreen.kt index e74f0aba38..62d6e14767 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositsScreen.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.screens.wallets.usdt import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -220,7 +221,7 @@ internal fun UsdtDepositsContent( val scrollState = rememberScrollState() val focusManager = LocalFocusManager.current LaunchedEffect(detail?.deposit?.id, showDetails) { scrollState.scrollTo(0) } - Column(modifier = Modifier.fillMaxSize().imePadding()) { + Column(modifier = Modifier.fillMaxSize().background(Colors.Black).imePadding()) { AppTopBar( titleText = stringResource( if (detail == null) R.string.usdt__deposit_history else R.string.usdt__receiving_title diff --git a/journeys/paykit-amount-layout.xml b/journeys/paykit-amount-layout.xml index 6c0b3d16c1..bbd4a1f046 100644 --- a/journeys/paykit-amount-layout.xml +++ b/journeys/paykit-amount-layout.xml @@ -4,7 +4,7 @@ Open Payment Requests and create a request for the test contact. Open Amount. Select USD and enter 1.50. Verify the amount is leading-aligned near the header, the BTC/USD controls are trailing-aligned directly below it, and a divider separates them from the keypad. Switch between BTC and USD several times. Verify the amount controls, divider and keypad stay at the same vertical positions, including when the Bitcoin unit button wraps below the denomination buttons at enlarged text sizes. - Verify Continue is visible or reachable by scrolling at enlarged text sizes. Continue to details and verify the request denomination and amount remain 1.50 USD. Cancel without sending. + Verify every keypad key remains legible and tappable on a short screen at enlarged text sizes, including zero, decimal/000 and Delete. Scroll as needed to reach all keys and Continue. Continue to details and verify the request denomination and amount remain 1.50 USD. Cancel without sending. Open Subscriptions and start creating a subscription for the same contact. Open Amount and repeat the layout check in BTC and USD. Verify the controls do not overlap the keypad or Continue. Return to subscription details and verify accepted payment methods remain either USDT or Bitcoin methods, never both groups together. Cancel without sending. Repeat the amount checks at a compact screen width and enlarged system text. Verify every control remains readable and reachable; controls may stack or wrap and the screen may scroll. diff --git a/journeys/usdt-wallet-receive-layout.xml b/journeys/usdt-wallet-receive-layout.xml index 2d8ba78447..86e9cf3418 100644 --- a/journeys/usdt-wallet-receive-layout.xml +++ b/journeys/usdt-wallet-receive-layout.xml @@ -2,6 +2,7 @@ Use an onboarded wallet with USDT enabled. For the hardware label check, use a paired hardware test wallet. No funds or payment submission are required. Open Savings from Home and return. Open USDT from Home and return. Verify both wallets use the same slide transition without showing the Home contents through the destination. + If saved USDT activity or cross-network deposits exist, open their details and return. Verify the slide transition keeps an opaque background without showing the previous screen through the details. Open Receive. Switch repeatedly between Savings, Spending and USDT. Verify Show Details stays at the same bottom position and no footer overlaps the system navigation area. On USDT, verify Network: Arbitrum One is visible. Open network selection and return without creating a deposit. Verify the network remains visible and Show Details still opens details. With a paired hardware wallet, switch between its receive tab and USDT. Verify the hardware tab retains its vendor name on every tab instead of changing to Hardware. From 47ffa203691b7a539bdab37f193ab0a72c8b7103 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 10:31:16 -0600 Subject: [PATCH 6/7] fix: keep compact qr codes scannable --- .../bitkit/ui/components/QrCodeImageTest.kt | 80 +++++++++++++++++++ .../to/bitkit/ui/components/QrCodeImage.kt | 8 +- changelog.d/next/1565.fixed.md | 2 +- 3 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 app/src/androidTest/java/to/bitkit/ui/components/QrCodeImageTest.kt diff --git a/app/src/androidTest/java/to/bitkit/ui/components/QrCodeImageTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/QrCodeImageTest.kt new file mode 100644 index 0000000000..0b77aa48a5 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/components/QrCodeImageTest.kt @@ -0,0 +1,80 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import com.google.zxing.BinaryBitmap +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.HybridBinarizer +import com.google.zxing.qrcode.QRCodeReader +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import to.bitkit.R +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface +import kotlin.test.assertEquals + +@HiltAndroidTest +@ComposeUi +class QrCodeImageTest { + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val compose = createComposeRule() + + @Before + fun setup() { + hiltRule.inject() + } + + @Test + fun displayedPaymentCodesDecodeWithLogoAtCompactAndRegularSizes() { + val address = "0x18503bD685aF84E6A7bC289898bAa1C3f5d8527E" + val usdt = "ethereum:0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9@42161/transfer?address=$address" + val cases = listOf( + usdt to R.drawable.tether_circle, + "$usdt&uint256=3500000" to R.drawable.tether_circle, + "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" to R.drawable.ic_btc_circle, + ) + var renderedContent: String? = null + val payload = mutableStateOf(cases.first()) + val width = mutableStateOf(120.dp) + compose.setContent { + AppThemeSurface { + QrCodeImage( + content = payload.value.first, + logoPainter = painterResource(payload.value.second), + onBitmapGenerated = { renderedContent = it?.let { payload.value.first } }, + modifier = Modifier.size(width.value).testTag("DisplayedQr") + ) + } + } + for (size in listOf(120.dp, 160.dp, 240.dp, 320.dp)) { + for (case in cases) { + compose.runOnIdle { + payload.value = case + width.value = size + } + compose.waitUntil(5000) { renderedContent == case.first } + compose.mainClock.advanceTimeBy(500) + val bitmap = compose.onNodeWithTag("DisplayedQr", useUnmergedTree = true) + .captureToImage().asAndroidBitmap() + val pixels = IntArray(bitmap.width * bitmap.height) + bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + val source = RGBLuminanceSource(bitmap.width, bitmap.height, pixels) + assertEquals(case.first, QRCodeReader().decode(BinaryBitmap(HybridBinarizer(source))).text) + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/components/QrCodeImage.kt b/app/src/main/java/to/bitkit/ui/components/QrCodeImage.kt index 504399d2d4..8e457c3c1b 100644 --- a/app/src/main/java/to/bitkit/ui/components/QrCodeImage.kt +++ b/app/src/main/java/to/bitkit/ui/components/QrCodeImage.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -70,7 +71,7 @@ fun QrCodeImage( val tooltipState = rememberTooltipState() val coroutineScope = rememberCoroutineScope() - Box( + BoxWithConstraints( contentAlignment = Alignment.Center, modifier = modifier .aspectRatio(1f) @@ -131,17 +132,18 @@ fun QrCodeImage( } logoPainter?.let { + val logoSize = minOf(68.dp, minOf(maxWidth, maxHeight) * 0.2f) Box( contentAlignment = Alignment.Center, modifier = Modifier - .size(68.dp) + .size(logoSize) .background(Color.White, shape = CircleShape) .align(Alignment.Center) ) { Image( painter = it, contentDescription = null, - modifier = Modifier.size(50.dp) + modifier = Modifier.size(logoSize * (50.dp / 68.dp)) ) } } diff --git a/changelog.d/next/1565.fixed.md b/changelog.d/next/1565.fixed.md index cd94607dbb..ca81dfb408 100644 --- a/changelog.d/next/1565.fixed.md +++ b/changelog.d/next/1565.fixed.md @@ -1 +1 @@ -Keep USDT wallet transitions and Receive controls consistent, and improve payment request and subscription amount layouts. +Keep USDT wallet transitions and Receive controls consistent, improve payment request and subscription amount layouts, and keep compact QR codes scannable. From a08ca5420125a49056d16f80cf0fd1abe265f022 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 12:07:37 -0600 Subject: [PATCH 7/7] fix: block usdt wallet tap passthrough --- .../java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt index 3e546d104e..a0d3438031 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt @@ -79,6 +79,7 @@ import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.UiConstants import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.blockPointerInputPassthrough import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.rememberBiometricAuthSupported @@ -329,7 +330,7 @@ private fun UsdtWalletContent( val allowSwipe by settings?.enableSwipeToHideBalance?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(false) } - Box(modifier = Modifier.fillMaxSize().background(Colors.Black)) { + Box(modifier = Modifier.fillMaxSize().background(Colors.Black).blockPointerInputPassthrough()) { UsdtCoinIllustration( modifier = Modifier.align(Alignment.TopEnd) .offset(x = 81.dp, y = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 4.dp)