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/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestAmountLocaleTest.kt index 5a54a6b2a3..31cc39d1d1 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,14 +2,37 @@ package to.bitkit.ui.screens.paymentrequests +import android.content.res.Configuration +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.LocalConfiguration +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 import org.junit.After import org.junit.Before @@ -24,6 +47,8 @@ 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 @@ -50,7 +75,10 @@ class PaymentRequestAmountLocaleTest { val amountInput = AmountInputViewModel(AmountInputHandler.stub()) composeTestRule.setContent { AppThemeSurface { - CompositionLocalProvider(LocalInspectionMode provides true) { + CompositionLocalProvider( + LocalInspectionMode provides true, + LocalConfiguration provides Configuration(LocalConfiguration.current).apply { setLocale(Locale.GERMANY) }, + ) { PaymentRequestAmountContent( amountInputViewModel = amountInput, initialDraft = PaykitPaymentRequestDraft( @@ -76,8 +104,105 @@ 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, 2f, PaykitAsset.USD)) + val height = mutableStateOf(468) + val amountInput = AmountInputViewModel(AmountInputHandler.stub()) + composeTestRule.setContent { + val (width, scale, asset) = configuration.value + CompositionLocalProvider( + LocalDensity provides Density(2f, scale), + LocalInspectionMode provides true, + LocalConfiguration provides Configuration(LocalConfiguration.current).apply { setLocale(Locale.GERMANY) }, + ) { + 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, height.value.dp) + ) + } + } + } + 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 { + height.value = screenHeight + configuration.value = Triple(width, scale, asset) + } + composeTestRule.waitForIdle() + 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).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.value, + divider.top.value, + composeTestRule.onNodeWithTag("N1").getUnclippedBoundsInRoot().top.value, + composeTestRule.onNodeWithTag("N0").getUnclippedBoundsInRoot().top.value, + ) + 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 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/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/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt index 6a5bc97dce..efc4912e1a 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,16 +4,22 @@ 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.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 @@ -27,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 @@ -34,11 +41,14 @@ 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.LocalConfiguration 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 @@ -69,7 +79,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 @@ -137,6 +146,7 @@ fun PaymentRequestAmountScreen( ) } +@OptIn(ExperimentalFoundationApi::class) @Composable internal fun PaymentRequestAmountContent( modifier: Modifier = Modifier, @@ -155,9 +165,27 @@ internal fun PaymentRequestAmountContent( var asset by remember(initialDraft.amount.asset) { mutableStateOf(initialDraft.amount.asset) } var dollars by remember(initialDraft.amount) { - mutableStateOf(if (initialDraft.amount.asset != PaykitAsset.BTC) initialDraft.amount.inputValue else "") + mutableStateOf( + if (initialDraft.amount.asset != PaykitAsset.BTC && initialDraft.amount.atomic > 0uL) { + initialDraft.amount.inputValue + } 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 { @@ -192,93 +220,136 @@ internal fun PaymentRequestAmountContent( } }, ) - BoxWithConstraints(modifier = Modifier.weight(1f)) { - val availableHeight = this.maxHeight - - Column(modifier = Modifier.fillMaxSize().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) - PaymentRequestAssetSelector( - asset = asset, - amount = entered, - allowedAssets = allowedAssets, - conversion = conversion, - onConverted = { - asset = it.asset - if (it.asset == PaykitAsset.BTC) { - amountInputViewModel.setSats(it.atomic.toLong(), currencies) + CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoView) { + BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight).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 { - dollars = if (it.atomic == 0uL) "" else it.inputValue + NumberPadAmountText( + value = dollars.ifEmpty { "0" }.replace('.', decimalSeparator), + symbol = "$", + modifier = Modifier.testTag("PaymentRequestAmountField") + ) } - conversionError = false - }, - onUnavailable = { conversionError = true }, - ) - if (conversionError) { - BodyS( - text = stringResource(R.string.wallet__payment_request_rate_unavailable), - color = Colors.White64 - ) - } - FillHeight(min = 12.dp) - if (asset == PaykitAsset.BTC) { - Row(modifier = Modifier.fillMaxWidth()) { - FillWidth() - UnitButton( - onClick = { amountInputViewModel.switchUnit(currencies) }, - color = Colors.Brand, - modifier = Modifier.testTag("PaymentRequestAmountUnit") + } + 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.inputValue + } + 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 ) } - } - VerticalSpacer(16.dp) - HorizontalDivider(color = Colors.White10) - if (asset == PaykitAsset.BTC) { - NumberPad( - viewModel = amountInputViewModel, - currencies = currencies, - availableHeight = availableHeight, - modifier = Modifier.testTag("PaymentRequestNumberPad") + 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, - availableHeight = availableHeight, - decimalSeparator = decimalSeparator.toString(), - modifier = Modifier.testTag("PaymentRequestNumberPad") + 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) } } } } +@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/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 d3e6afe93c..81b081f470 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 @@ -308,6 +308,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( @@ -317,11 +321,13 @@ fun ReceiveQrScreen( UsdtDepositReceiveScreen( viewModel = hiltViewModel(), tabs = visibleTabs, + tabLabels = tabLabels, onSelectTab = ::selectReceiveTab, onBack = onClose, onBlockingChange = onBlockingChange, onContacts = onClickPaymentRequestContacts.takeIf { showPaymentRequestContacts }, ) + VerticalSpacer(16.dp) } return } @@ -360,9 +366,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/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/UsdtDepositReceiveScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositReceiveScreen.kt index 1954bc033a..92f01d4671 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 @@ -35,6 +35,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 @@ -65,6 +67,7 @@ internal fun ColumnScope.UsdtDepositReceiveScreen( viewModel: UsdtViewModel, tabs: ImmutableList, onSelectTab: (ReceiveTab) -> Unit, + tabLabels: ImmutableMap = persistentMapOf(), onBack: () -> Unit, onBlockingChange: (Boolean) -> Unit, onContacts: (() -> Unit)? = null, @@ -129,6 +132,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/UsdtDepositsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositsScreen.kt index 212845f9eb..d51e8cf8f5 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 @@ -225,7 +226,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/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 6d30faeb66..18e947b7cc 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 @@ -331,7 +332,7 @@ private fun UsdtWalletContent( val allowSwipe by settings?.enableSwipeToHideBalance?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(false) } - Box(modifier = Modifier.fillMaxSize()) { + 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) diff --git a/changelog.d/next/1565.fixed.md b/changelog.d/next/1565.fixed.md new file mode 100644 index 0000000000..ca81dfb408 --- /dev/null +++ b/changelog.d/next/1565.fixed.md @@ -0,0 +1 @@ +Keep USDT wallet transitions and Receive controls consistent, improve payment request and subscription amount layouts, and keep compact QR codes scannable. diff --git a/journeys/paykit-amount-layout.xml b/journeys/paykit-amount-layout.xml new file mode 100644 index 0000000000..90b711d052 --- /dev/null +++ b/journeys/paykit-amount-layout.xml @@ -0,0 +1,12 @@ + + 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. + 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 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. Select USDT before opening Amount. Starting from zero, enter 1.50 without deleting first. Continue and verify the subscription amount is 1.50 USD. Reopen Amount and verify the existing cents are preserved. 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..86e9cf3418 --- /dev/null +++ b/journeys/usdt-wallet-receive-layout.xml @@ -0,0 +1,12 @@ + + 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. + 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. + +