From 9112565e3f32f951bb237b31c2a9caced8ac1ba7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 18 Oct 2026 16:44:14 -0600 Subject: [PATCH 1/8] fix: improve usdt payment feedback and layout --- .../PaymentRequestsScreenTest.kt | 37 ++++++++++++ .../send/SendContactSelectScreenTest.kt | 49 +++++++++++++++ .../java/to/bitkit/ui/components/Money.kt | 3 + .../bitkit/ui/components/PaykitAmountView.kt | 11 ++-- .../bitkit/ui/components/PubkyContactRow.kt | 24 ++++---- .../paymentrequests/PaymentRequestsScreen.kt | 1 + .../wallets/send/SendContactSelectScreen.kt | 6 +- .../send/SendContactSelectViewModel.kt | 59 ++++++++++--------- .../screens/wallets/usdt/PaykitUsdtReview.kt | 3 +- .../wallets/usdt/UsdtActivityDetail.kt | 3 +- .../wallets/usdt/UsdtDepositReceiveScreen.kt | 3 +- .../send/SendContactSelectViewModelTest.kt | 50 ++++++++++++++++ changelog.d/next/1523.fixed.md | 1 + .../contact-request-or-pay.xml | 1 + journeys/payment-requests/request-summary.xml | 1 + journeys/usdt-bridge-details.xml | 1 + journeys/usdt-payment-links-and-scans.xml | 1 + journeys/usdt-receive-amount-and-units.xml | 2 +- journeys/usdt-send-review-and-details.xml | 1 + 19 files changed, 208 insertions(+), 49 deletions(-) create mode 100644 app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreenTest.kt create mode 100644 app/src/test/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModelTest.kt create mode 100644 changelog.d/next/1523.fixed.md diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index 0b9b958700..babe51e340 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -2,10 +2,15 @@ package to.bitkit.ui.screens.paymentrequests +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable 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.semantics.SemanticsActions import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled @@ -17,6 +22,10 @@ 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.performSemanticsAction +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp import com.synonym.paykit.PaymentRequestLifecycleState import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf @@ -39,6 +48,8 @@ import to.bitkit.repositories.PaykitSubscriptionRecurrence import to.bitkit.test.annotations.ComposeUi import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.theme.AppThemeSurface +import kotlin.test.assertFalse +import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -300,6 +311,32 @@ class PaymentRequestsScreenTest { composeTestRule.onNodeWithTag("PaymentRequestCreate").assertDoesNotExist() } + @Test + fun longReceiptStatusKeepsContactAndExactAmountReadable() { + val status = "Płatność zweryfikowana · 0.001647 USDT" + composeTestRule.setContent { + PaymentRequestsTestSurface { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, 1.3f)) { + Box(Modifier.width(320.dp)) { + PaymentRequestCard( + request = request().copy(amount = PaykitAmount(PaykitAsset.USDT, 1_647u)), + contact = PubkyProfile.forDisplay(request().counterparty, "Alice", imageUrl = null), + fiatStatus = status, + ) + } + } + } + } + for (text in listOf("Alice", status)) { + val results = mutableListOf() + composeTestRule.onNodeWithText(text).assertIsDisplayed() + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) } + assertTrue(results.isNotEmpty()) + assertFalse(results.single().hasVisualOverflow, "$text must fit without truncation") + } + } + private fun setOutgoingRequestContent(primaryDisplay: PrimaryDisplay) { val outgoing = request(id = "outgoing").copy( createdAt = Clock.System.now(), diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreenTest.kt new file mode 100644 index 0000000000..a721c63539 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreenTest.kt @@ -0,0 +1,49 @@ +package to.bitkit.ui.screens.wallets.send + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import to.bitkit.models.PubkyProfile +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface +import kotlin.test.assertEquals + +@ComposeUi +class SendContactSelectScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun resolvingContactShowsProgressAndPreventsAnotherSelection() { + val state = mutableStateOf( + SendContactSelectUiState( + contacts = persistentListOf(PubkyProfile.placeholder("alice"), PubkyProfile.placeholder("bob")), + resolvingPublicKey = "alice", + ) + ) + var selected: String? = null + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + SendContactSelectContent(uiState = state.value, onContactClick = { selected = it }) + } + } + } + composeTestRule.onNodeWithTag("SendContactLoading_alice", useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNodeWithTag("SendContactLoading_bob", useUnmergedTree = true).assertDoesNotExist() + composeTestRule.onNodeWithTag("SendContact_bob").assertIsNotEnabled() + composeTestRule.runOnIdle { state.value = state.value.copy(resolvingPublicKey = null) } + composeTestRule.onNodeWithTag("SendContactLoading_alice", useUnmergedTree = true).assertDoesNotExist() + composeTestRule.onNodeWithTag("SendContact_bob").assertIsEnabled().performClick() + composeTestRule.runOnIdle { assertEquals("bob", selected) } + } +} diff --git a/app/src/main/java/to/bitkit/ui/components/Money.kt b/app/src/main/java/to/bitkit/ui/components/Money.kt index 9f72bae8d1..34c944c908 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import to.bitkit.models.BITCOIN_SYMBOL @@ -90,6 +91,7 @@ fun MoneyCell( primary?.let { text -> BodyMSB( text = (if (primaryIsReplaced) text else "$prefix$text").withAccent(accentColor = Colors.White64), + textAlign = TextAlign.End, modifier = Modifier.testTag("MoneyPrimary"), ) } @@ -97,6 +99,7 @@ fun MoneyCell( CaptionB( text = text.withAccent(accentColor = Colors.White64), color = Colors.White64, + textAlign = TextAlign.End, modifier = Modifier.testTag("MoneySecondary"), ) } diff --git a/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt b/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt index 5251f42b2e..0fd80ac0f9 100644 --- a/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt +++ b/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import to.bitkit.models.PaykitAmount import to.bitkit.models.PaykitAsset import to.bitkit.ui.theme.Colors @@ -41,16 +42,18 @@ fun PaykitAmountDisplay(amount: PaykitAmount, modifier: Modifier = Modifier, pre @Composable fun PaykitAmountCell( amount: PaykitAmount, + modifier: Modifier = Modifier, prefix: String = "", status: String? = null, - showBitcoinSymbol: Boolean = true + showBitcoinSymbol: Boolean = true, ) { if (amount.asset == PaykitAsset.BTC) { MoneyCell( sats = amount.atomic.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), prefix = prefix, showBitcoinSymbol = showBitcoinSymbol, - fiatReplacement = status + fiatReplacement = status, + modifier = modifier ) } else { val locale = Locale.getDefault() @@ -61,9 +64,9 @@ fun PaykitAmountCell( usdDisplayAmount(amount.atomic, locale) } } - Column(horizontalAlignment = Alignment.End) { + Column(horizontalAlignment = Alignment.End, modifier = modifier) { BodyMSB(text = prefix + "$" + displayAmount) - status?.let { BodyS(text = it, color = Colors.White64) } + status?.let { BodyS(text = it, color = Colors.White64, textAlign = TextAlign.End) } } } } diff --git a/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt b/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt index ddb83c7426..2a6488a0fe 100644 --- a/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.disabled @@ -32,6 +33,7 @@ fun PubkyContactRow( modifier: Modifier = Modifier, isSelected: Boolean? = null, isEnabled: Boolean = true, + isLoading: Boolean = false, verticalPadding: Dp = 12.dp, selectionColor: Color = Colors.PubkyGreen, ) { @@ -41,17 +43,11 @@ fun PubkyContactRow( modifier = modifier .fillMaxWidth() .clickableAlpha(enabled = isEnabled, onClick = onClick) - .then( - if (isSelected == null) { - Modifier - } else { - Modifier.semantics { - role = Role.RadioButton - selected = isSelected - if (!isEnabled) disabled() - } - } - ) + .semantics { + role = if (isSelected == null) Role.Button else Role.RadioButton + isSelected?.let { selected = it } + if (!isEnabled) disabled() + } .padding(vertical = verticalPadding) ) { PubkyContactAvatar(profile = profile) @@ -71,7 +67,11 @@ fun PubkyContactRow( overflow = TextOverflow.Ellipsis, ) } - if (isSelected == true) { + if (isLoading) { + GradientCircularProgressIndicator( + modifier = Modifier.size(24.dp).testTag("SendContactLoading_${profile.publicKey}") + ) + } else if (isSelected == true) { Icon( painter = painterResource(R.drawable.ic_check), contentDescription = null, diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index ab0b13b356..34a0788d1c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -625,6 +625,7 @@ internal fun PaymentRequestCard( prefix = amountPrefix, showBitcoinSymbol = showBitcoinSymbol, status = paymentRequestAmountStatus(request, receipt, fiatStatus), + modifier = Modifier.weight(1f) ) } if (onPay != null || onDismiss != null) { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt index 7c127898e0..dc27c91d9d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt @@ -57,7 +57,7 @@ fun SendContactSelectScreen( } @Composable -private fun SendContactSelectContent( +internal fun SendContactSelectContent( uiState: SendContactSelectUiState, onBack: () -> Unit = {}, onContactClick: (String) -> Unit = {}, @@ -100,6 +100,7 @@ private fun SendContactSelectContent( else -> SendContactList( contacts = uiState.contacts, + resolvingPublicKey = uiState.resolvingPublicKey, onContactClick = onContactClick, ) } @@ -109,6 +110,7 @@ private fun SendContactSelectContent( @Composable private fun SendContactList( contacts: ImmutableList, + resolvingPublicKey: String?, onContactClick: (String) -> Unit, ) { LazyColumn( @@ -119,6 +121,8 @@ private fun SendContactList( items(contacts, key = { it.publicKey }) { contact -> PubkyContactRow( profile = contact, + isEnabled = resolvingPublicKey == null, + isLoading = resolvingPublicKey == contact.publicKey, onClick = { onContactClick(contact.publicKey) }, modifier = Modifier.testTag("SendContact_${contact.publicKey}") ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt index bc31cc868d..b92fb9dc1b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt @@ -64,36 +64,39 @@ class SendContactSelectViewModel @Inject constructor( } fun payContact(publicKey: String) { - if (_uiState.value.isResolvingPayment) return + if (_uiState.value.resolvingPublicKey != null) return + _uiState.update { it.copy(resolvingPublicKey = publicKey) } viewModelScope.launch { - _uiState.update { it.copy(isResolvingPayment = true) } - privatePaykitRepo.beginSavedContactPayment(publicKey) - .onSuccess { result -> - when (result) { - is PublicPaykitPaymentResult.Opened -> - _effects.emit( - SendContactSelectEffect.OpenPayment( - result.paymentRequest, - publicKey, - result.privatePaymentContext, - result.endpoints, + try { + privatePaykitRepo.beginSavedContactPayment(publicKey) + .onSuccess { result -> + when (result) { + is PublicPaykitPaymentResult.Opened -> + _effects.emit( + SendContactSelectEffect.OpenPayment( + result.paymentRequest, + publicKey, + result.privatePaymentContext, + result.endpoints, + ) ) - ) - PublicPaykitPaymentResult.NoEndpoint -> - showPayError(R.string.slashtags__error_pay_empty_msg) - PublicPaykitPaymentResult.NotOpened -> - showPayError(R.string.slashtags__error_pay_not_opened_msg) - PublicPaykitPaymentResult.PrivateLinkPending, - PublicPaykitPaymentResult.WaitingForUpdatedPaymentList, - -> - showPayError(R.string.slashtags__error_pay_waiting_msg) + PublicPaykitPaymentResult.NoEndpoint -> + showPayError(R.string.slashtags__error_pay_empty_msg) + PublicPaykitPaymentResult.NotOpened -> + showPayError(R.string.slashtags__error_pay_not_opened_msg) + PublicPaykitPaymentResult.PrivateLinkPending, + PublicPaykitPaymentResult.WaitingForUpdatedPaymentList, + -> + showPayError(R.string.slashtags__error_pay_waiting_msg) + } } - } - .onFailure { - Logger.warn("Failed to begin contact payment", it, context = TAG) - showPayError(R.string.slashtags__error_pay_not_opened_msg) - } - _uiState.update { it.copy(isResolvingPayment = false) } + .onFailure { + Logger.warn("Failed to begin contact payment", it, context = TAG) + showPayError(R.string.slashtags__error_pay_not_opened_msg) + } + } finally { + _uiState.update { it.copy(resolvingPublicKey = null) } + } } } @@ -114,7 +117,7 @@ class SendContactSelectViewModel @Inject constructor( data class SendContactSelectUiState( val contacts: ImmutableList = persistentListOf(), val isLoading: Boolean = false, - val isResolvingPayment: Boolean = false, + val resolvingPublicKey: String? = null, ) sealed interface SendContactSelectEffect { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt index fc5f533ded..91e539a651 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt @@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -89,7 +90,7 @@ fun PaykitUsdtReview( UsdtActivityDetail(transfer, settings.hideBalance, wallet.address) { details = false } } else { Column( - Modifier.sheetHeight().background(Colors.Gray7) + Modifier.sheetHeight().background(Colors.Gray7).navigationBarsPadding() ) { if (state.authenticationRequired) { Box(modifier = Modifier.padding(horizontal = 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 1aedaf3cc5..9d2841eea0 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 @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size @@ -178,7 +179,7 @@ internal fun UsdtActivityDetail( if (showDetails && (transfer.txHash != null || canOpenDeposits)) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(16.dp) + modifier = Modifier.navigationBarsPadding().padding(16.dp) ) { if (canOpenDeposits) { PrimaryButton( 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 fceb659049..b5af7261b4 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 @@ -225,7 +225,8 @@ private fun ColumnScope.Content( uri = state.address?.uri ?: uri, error = receiveError, network = state.network?.label ?: "Arbitrum One", - amount = state.address?.let { usdtFormatAmount(it.amount) } ?: amount, + amount = state.address?.let { usdtFormatAmount(it.amount) } + ?: atomic?.let { usdtFormatAmount(it) }.orEmpty(), onEdit = onRefresh, onNetwork = { onPage(ReceivePage.NETWORKS) }, ) diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModelTest.kt new file mode 100644 index 0000000000..fdf20f81a6 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModelTest.kt @@ -0,0 +1,50 @@ +package to.bitkit.ui.screens.wallets.send + +import android.content.Context +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitPaymentResult +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class SendContactSelectViewModelTest : BaseUnitTest() { + @Test + fun `contact stays busy until resolution finishes and can be retried`() = test { + val context: Context = mock() + val contacts: PubkyRepo = mock() + val payments: PrivatePaykitRepo = mock() + whenever(context.getString(any())).thenReturn("") + whenever(contacts.contacts).thenReturn(MutableStateFlow(emptyList())) + whenever(contacts.isLoadingContacts).thenReturn(MutableStateFlow(false)) + val result = CompletableDeferred>() + whenever(payments.beginSavedContactPayment("alice")).doSuspendableAnswer { result.await() } + val model = SendContactSelectViewModel(context, contacts, payments) + + model.payContact("alice") + model.payContact("bob") + advanceUntilIdle() + assertEquals("alice", model.uiState.value.resolvingPublicKey) + verify(payments, times(0)).beginSavedContactPayment("bob") + + result.complete(Result.success(PublicPaykitPaymentResult.NoEndpoint)) + advanceUntilIdle() + assertNull(model.uiState.value.resolvingPublicKey) + model.payContact("alice") + advanceUntilIdle() + verify(payments, times(2)).beginSavedContactPayment("alice") + assertNull(model.uiState.value.resolvingPublicKey) + } +} diff --git a/changelog.d/next/1523.fixed.md b/changelog.d/next/1523.fixed.md new file mode 100644 index 0000000000..ec422dff3e --- /dev/null +++ b/changelog.d/next/1523.fixed.md @@ -0,0 +1 @@ +Improved USDT payment layout, contact loading feedback and receive sharing amounts. diff --git a/journeys/payment-requests/contact-request-or-pay.xml b/journeys/payment-requests/contact-request-or-pay.xml index 11d800076a..cbf6dfcd44 100644 --- a/journeys/payment-requests/contact-request-or-pay.xml +++ b/journeys/payment-requests/contact-request-or-pay.xml @@ -16,5 +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. If resolution fails, verify the error is shown and contact selection becomes available for retry. Cancel without paying. diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index 0ccf8d31d1..491c8a8b96 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -19,5 +19,6 @@ On the payer, wait for preparation to finish and swipe to pay the 5,000 sats request On the requester, close any received-payment sheet covering the Sent receipt, leaving the receipt open underneath Keep the requester foregrounded through its existing Paykit synchronization; after the protocol payment proof arrives, verify the same Sent receipt shows "Proof submitted" instead of "Waiting for payment", while its PAYMENT REQUESTED heading remains unchanged + Open existing USDT payment request history with a verified receipt containing an exact six-decimal amount. With enlarged text and a long translated verification status, verify the contact name stays readable and the complete status wraps within the amount column. Repeat for a BTC-denominated request paid in USDT. diff --git a/journeys/usdt-bridge-details.xml b/journeys/usdt-bridge-details.xml index a8e02c7a6d..5248020512 100644 --- a/journeys/usdt-bridge-details.xml +++ b/journeys/usdt-bridge-details.xml @@ -15,5 +15,6 @@ Return to the USDT activity list. Verify Pending uses a yellow hourglass and 16% yellow circle, Failed uses a red x-circle and 16% red circle, and Transferring uses the teal transfer arrows. Sent and Received remain teal. Open USDT Receive and inject one confirmed incoming USDT transfer through the isolated fixture. Verify Receive is replaced by one received celebration. Dismiss it and verify the same transfer is not celebrated again; a different subsequent transfer must still be shown. Open USDT Receive, then Network. Verify Cross-Network Deposits (id "UsdtDepositHistory") is a full-width primary button pinned over the bottom of the network list. Scroll the list and verify the rows and artwork pass behind the button while it stays fixed. Scroll to the end and verify the last content can be brought above the button. Repeat with large text, verify the button label is not clipped, then tap it and verify deposit history opens before any Arbitrum receipt exists. + On iOS, select the largest accessibility text size. Open Cross-Network Deposits and verify each row shows the full source network, status and amount without truncation. Open a deposit and verify Status and Receiving Network stack vertically and remain readable. Return to the default text size and verify the compact row and two-column summary return. diff --git a/journeys/usdt-payment-links-and-scans.xml b/journeys/usdt-payment-links-and-scans.xml index 89006270c7..8fda0a4a03 100644 --- a/journeys/usdt-payment-links-and-scans.xml +++ b/journeys/usdt-payment-links-and-scans.xml @@ -18,5 +18,6 @@ Enter another owned wallet’s Arbitrum address, manually enter the entire available USDT balance and continue. Verify payment review stays closed and an actionable fee or balance error asks for a smaller amount: "Not enough USDT to cover the amount and fees." or "Unable to pay the transaction fee in USDT. Try a smaller amount." Reduce the amount, reach review and close without signing. With an empty wallet and no pending transfers, turn USDT off in Settings. Copy an owned bare USDT address, return to the app, and verify no automatic payment or enable prompt. Repeat with an amount-bearing USDT payment URI. Copy a valid Bitcoin address and a Lightning invoice in turn; verify their normal clipboard prompts still appear and cancel without paying. With USDT off, explicitly open the same USDT URI and verify the designed Enable USDT modal appears. Decline and verify a quiet return with no stuck spinner. Scan its QR, enable USDT, and verify the original recipient and exact amount resume. Cancel without paying. Turn USDT off again and repeat the explicit scan, decline and enable checks with the owned bare 0x address; verify the recipient resumes with an empty amount. + On iOS with camera permission enabled and the largest accessibility text size, open USDT Send. Start an upward drag inside the camera preview and verify the recipient screen scrolls so Paste, Enter Manually and the network warning are reachable. Verify the photo picker and flashlight controls still respond. Restore the default text size without sending. diff --git a/journeys/usdt-receive-amount-and-units.xml b/journeys/usdt-receive-amount-and-units.xml index 857d682e1e..92900afd2b 100644 --- a/journeys/usdt-receive-amount-and-units.xml +++ b/journeys/usdt-receive-amount-and-units.xml @@ -5,7 +5,7 @@ Open USDT from Home. Verify the USDT balance, teal Tether icon and existing activity rows. Open Receive. Verify Receive Dollars, the USDT tab and Network: Arbitrum One on the QR screen. Tap Edit, enter 5 and tap Back. Verify the QR has no uint256 amount. - Tap Edit. Enter 3.5. Verify the dollar amount and BTC equivalent are displayed, there is no input-unit toggle, the keypad follows the divider, and Continue stays at the bottom without overlap. + Tap Edit. Enter 3.50. Verify the dollar amount and BTC equivalent are displayed, there is no input-unit toggle, the keypad follows the divider, and Continue stays at the bottom without overlap. Continue to the QR. Verify it requests the Arbitrum USDT token, the wallet address and uint256=3500000, never Bitcoin. Tap Edit, enter a different amount and tap Back. Verify the QR still requests uint256=3500000. Tap Copy on the QR screen and verify the clipboard contains only the receiving address. Tap the QR and verify it copies the same address, while the QR still encodes the payment URI including uint256=3500000. diff --git a/journeys/usdt-send-review-and-details.xml b/journeys/usdt-send-review-and-details.xml index 64186accf5..b5af303ab4 100644 --- a/journeys/usdt-send-review-and-details.xml +++ b/journeys/usdt-send-review-and-details.xml @@ -14,5 +14,6 @@ Open an existing sent USDT activity. Verify the heading is Sent USDT, the signed amount includes the actual fee, and summary rows show Status/Network, Date/Time and Payment/Fee with teal icons. Open Details and verify transaction ID, known sender, recipient and source/destination networks; Open Block Explorer stays at the bottom. Open an existing received activity. Verify Received USDT and a positive amount. Verify Details shows the verified sender when available and does not invent one for older unknown records. Check pending, failed and transferring rows use their matching icons, and transferring shows its destination. Prepare a contact payment that accepts multiple payment methods. Open the payment-source button and verify the balance menu shows Savings, Spending, any paired hardware wallet and USDT. Unsupported methods are disabled and shown at 30% opacity. Verify the menu has 24px outer padding, 24px asset icons, 13/18 uppercase labels and 17/22 balances, with no extra transfer icon next to Spending. Select a supported method and verify its label, available balance and payable amount update. Cancel without paying. + On Android, repeat contact, one-time request and recurring request USDT confirmation on a compact device with three-button navigation, then gesture navigation. Verify Swipe To Pay stays above the navigation area in both collapsed and expanded views. Open incoming activity Details and verify Cross-Network Deposits remains fully above the navigation area. Cancel without paying. From 95f57d4861420192636d7e078387cdec6589063d Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 18 Oct 2026 17:23:07 -0600 Subject: [PATCH 2/8] fix: expose contact loading and verify entered receive amounts --- .../ui/components/UsdtReceivePrivacyTest.kt | 33 +++++++++++++++++-- .../send/SendContactSelectScreenTest.kt | 11 ++++++- .../bitkit/ui/components/PubkyContactRow.kt | 3 ++ .../wallets/usdt/UsdtDepositReceiveScreen.kt | 6 ++-- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/components/UsdtReceivePrivacyTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/UsdtReceivePrivacyTest.kt index 4cf16128c2..d2ebb6eead 100644 --- a/app/src/androidTest/java/to/bitkit/ui/components/UsdtReceivePrivacyTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/components/UsdtReceivePrivacyTest.kt @@ -41,6 +41,10 @@ import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.test.annotations.ComposeUi +import to.bitkit.repositories.UsdtWalletState +import to.bitkit.ui.screens.wallets.usdt.DepositUiState +import to.bitkit.ui.screens.wallets.usdt.UsdtDepositReceiveContent +import to.bitkit.ui.screens.wallets.usdt.ReceivePage import to.bitkit.ui.screens.wallets.usdt.UsdtReceiveContent import to.bitkit.ui.sheets.NewTransactionSheetView import to.bitkit.ui.theme.AppThemeSurface @@ -75,7 +79,8 @@ class UsdtReceivePrivacyTest { val polygonUri = "ethereum:0xc2132D05D31c914a87C6611C10748AEb04B58e8F@137/transfer?address=$deposit" val cases = listOf( ReceiveCase(wallet, "Arbitrum One", arbitrumUri), - ReceiveCase(wallet, "Arbitrum One", "$arbitrumUri&uint256=1234567", "1.234567"), + ReceiveCase(wallet, "Arbitrum One", "$arbitrumUri&uint256=3500000", "3.5", "3.50"), + ReceiveCase(wallet, "Arbitrum One", "$arbitrumUri&uint256=1", "0.000001", "0.000001"), ReceiveCase(deposit, "Polygon", "$polygonUri&uint256=3500000", "3.5"), ReceiveCase(wallet, "Arbitrum One", arbitrumUri), ) @@ -94,7 +99,23 @@ class UsdtReceivePrivacyTest { AppThemeSurface { Column { val receive = current.value - UsdtReceiveContent(receive.address, receive.uri, null, receive.network, {}, {}, receive.amount) + if (receive.inputAmount != null) { + UsdtDepositReceiveContent( + page = ReceivePage.ADDRESS, + onPage = {}, + wallet = UsdtWalletState(address = wallet, receiveUri = arbitrumUri), + receiveError = null, + state = DepositUiState(), + amount = receive.inputAmount, + onNetwork = {}, + onAmount = {}, + onCreate = {}, + onHistory = {}, + onRefresh = {}, + ) + } else { + UsdtReceiveContent(receive.address, receive.uri, null, receive.network, {}, {}, receive.amount) + } } } } @@ -142,7 +163,13 @@ class UsdtReceivePrivacyTest { } } - private data class ReceiveCase(val address: String, val network: String, val uri: String, val amount: String = "") + private data class ReceiveCase( + val address: String, + val network: String, + val uri: String, + val amount: String = "", + val inputAmount: String? = null, + ) @Test fun incomingPaymentHonorsHiddenBalance() { diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreenTest.kt index a721c63539..5842e06eb6 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreenTest.kt @@ -3,6 +3,10 @@ package to.bitkit.ui.screens.wallets.send import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled @@ -40,7 +44,12 @@ class SendContactSelectScreenTest { } composeTestRule.onNodeWithTag("SendContactLoading_alice", useUnmergedTree = true).assertIsDisplayed() composeTestRule.onNodeWithTag("SendContactLoading_bob", useUnmergedTree = true).assertDoesNotExist() - composeTestRule.onNodeWithTag("SendContact_bob").assertIsNotEnabled() + composeTestRule.onNodeWithTag("SendContact_alice").assertIsNotEnabled().assert( + SemanticsMatcher.expectValue(SemanticsProperties.ProgressBarRangeInfo, ProgressBarRangeInfo.Indeterminate) + ) + composeTestRule.onNodeWithTag("SendContact_bob").assertIsNotEnabled().assert( + SemanticsMatcher.keyNotDefined(SemanticsProperties.ProgressBarRangeInfo) + ) composeTestRule.runOnIdle { state.value = state.value.copy(resolvingPublicKey = null) } composeTestRule.onNodeWithTag("SendContactLoading_alice", useUnmergedTree = true).assertDoesNotExist() composeTestRule.onNodeWithTag("SendContact_bob").assertIsEnabled().performClick() diff --git a/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt b/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt index 2a6488a0fe..dfbc964401 100644 --- a/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt @@ -13,8 +13,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics @@ -47,6 +49,7 @@ fun PubkyContactRow( role = if (isSelected == null) Role.Button else Role.RadioButton isSelected?.let { selected = it } if (!isEnabled) disabled() + if (isLoading) progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate } .padding(vertical = verticalPadding) ) { 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 b5af7261b4..77d24e8717 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 @@ -133,7 +133,7 @@ internal fun ColumnScope.UsdtDepositReceiveScreen( VerticalSpacer(16.dp) } walletUi.error?.let { BodyS(stringResource(it), color = Colors.Brand) } - Content( + UsdtDepositReceiveContent( page = page, onPage = { page = it }, wallet = wallet, @@ -174,7 +174,7 @@ internal fun ColumnScope.UsdtDepositReceiveScreen( } @Composable -private fun ColumnScope.Content( +internal fun ColumnScope.UsdtDepositReceiveContent( page: ReceivePage, onPage: (ReceivePage) -> Unit, wallet: UsdtWalletState, @@ -234,7 +234,7 @@ private fun ColumnScope.Content( } } -private enum class ReceivePage { ADDRESS, NETWORKS, AMOUNT, FEES } +internal enum class ReceivePage { ADDRESS, NETWORKS, AMOUNT, FEES } @Composable private fun ColumnScope.NetworkSelector( From fccb1f9a9eaae28f7967b9a7505f25b389b96847 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 18 Oct 2026 18:03:57 -0600 Subject: [PATCH 3/8] fix: retain contact progress through navigation --- .../PaymentRequestsScreenTest.kt | 26 ++++++++ .../java/to/bitkit/ui/components/Money.kt | 10 ++- .../bitkit/ui/components/PaykitAmountView.kt | 9 ++- .../paymentrequests/PaymentRequestsScreen.kt | 61 ++++++++++--------- .../wallets/send/SendContactSelectScreen.kt | 7 ++- .../send/SendContactSelectViewModel.kt | 16 ++++- .../wallets/usdt/UsdtActivityDetail.kt | 4 +- .../send/SendContactSelectViewModelTest.kt | 38 ++++++++++++ 8 files changed, 136 insertions(+), 35 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index babe51e340..76898136bb 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -337,6 +337,32 @@ class PaymentRequestsScreenTest { } } + @Test + fun shortAmountLeavesRoomForContactNameAtLargeText() { + val name = "Alexandra Payer" + composeTestRule.setContent { + PaymentRequestsTestSurface { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, 1.3f)) { + Box(Modifier.width(375.dp)) { + PaymentRequestCard( + request = request().copy(amount = PaykitAmount(PaykitAsset.USD, 200u)), + contact = PubkyProfile.forDisplay(request().counterparty, name, imageUrl = null), + fiatStatus = "Pending", + ) + } + } + } + } + for (text in listOf(name, "$2.00", "Pending")) { + val results = mutableListOf() + composeTestRule.onNodeWithText(text).assertIsDisplayed() + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) } + assertTrue(results.isNotEmpty()) + assertFalse(results.single().hasVisualOverflow, "$text must fit without truncation") + } + } + private fun setOutgoingRequestContent(primaryDisplay: PrimaryDisplay) { val outgoing = request(id = "outgoing").copy( createdAt = Clock.System.now(), diff --git a/app/src/main/java/to/bitkit/ui/components/Money.kt b/app/src/main/java/to/bitkit/ui/components/Money.kt index 34c944c908..ba62c7d6c2 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import to.bitkit.models.BITCOIN_SYMBOL @@ -60,6 +61,7 @@ fun MoneyCell( prefix: String = "", showBitcoinSymbol: Boolean = true, fiatReplacement: String? = null, + maxAmountLines: Int = Int.MAX_VALUE, ) { val currencies = LocalCurrencies.current val primaryUnit = currencies.primaryDisplay @@ -77,7 +79,9 @@ fun MoneyCell( ) val primaryIsReplaced = fiatReplacement != null && primaryUnit == PrimaryDisplay.FIAT val primary = if (primaryIsReplaced) fiatReplacement else primaryMoney - val secondary = if (fiatReplacement != null && secondaryUnit == PrimaryDisplay.FIAT) { + val secondaryIsReplaced = fiatReplacement != null && secondaryUnit == PrimaryDisplay.FIAT + val amountOverflow = if (maxAmountLines == Int.MAX_VALUE) TextOverflow.Clip else TextOverflow.Ellipsis + val secondary = if (secondaryIsReplaced) { fiatReplacement } else { secondaryMoney @@ -92,6 +96,8 @@ fun MoneyCell( BodyMSB( text = (if (primaryIsReplaced) text else "$prefix$text").withAccent(accentColor = Colors.White64), textAlign = TextAlign.End, + maxLines = if (primaryIsReplaced) Int.MAX_VALUE else maxAmountLines, + overflow = amountOverflow, modifier = Modifier.testTag("MoneyPrimary"), ) } @@ -100,6 +106,8 @@ fun MoneyCell( text = text.withAccent(accentColor = Colors.White64), color = Colors.White64, textAlign = TextAlign.End, + maxLines = if (secondaryIsReplaced) Int.MAX_VALUE else maxAmountLines, + overflow = amountOverflow, modifier = Modifier.testTag("MoneySecondary"), ) } diff --git a/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt b/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt index 0fd80ac0f9..2a473723b6 100644 --- a/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt +++ b/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import to.bitkit.models.PaykitAmount import to.bitkit.models.PaykitAsset import to.bitkit.ui.theme.Colors @@ -46,6 +47,7 @@ fun PaykitAmountCell( prefix: String = "", status: String? = null, showBitcoinSymbol: Boolean = true, + maxAmountLines: Int = Int.MAX_VALUE, ) { if (amount.asset == PaykitAsset.BTC) { MoneyCell( @@ -53,6 +55,7 @@ fun PaykitAmountCell( prefix = prefix, showBitcoinSymbol = showBitcoinSymbol, fiatReplacement = status, + maxAmountLines = maxAmountLines, modifier = modifier ) } else { @@ -65,7 +68,11 @@ fun PaykitAmountCell( } } Column(horizontalAlignment = Alignment.End, modifier = modifier) { - BodyMSB(text = prefix + "$" + displayAmount) + BodyMSB( + text = prefix + "$" + displayAmount, + maxLines = maxAmountLines, + overflow = if (maxAmountLines == Int.MAX_VALUE) TextOverflow.Clip else TextOverflow.Ellipsis, + ) status?.let { BodyS(text = it, color = Colors.White64, textAlign = TextAlign.End) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index 34a0788d1c..1fc2d8a569 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border 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.PaddingValues import androidx.compose.foundation.layout.Row @@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card @@ -597,36 +599,39 @@ internal fun PaymentRequestCard( .clickableAlpha(enabled = onClick != null) { onClick?.invoke() } .testTag("PaymentRequestRow-${request.paymentRequestId}") ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(16.dp), - ) { - if (request.showsPaymentRailIcon(isOutgoingPayment)) { - PaymentRailIcon(request = request, paymentWasSent = request.paymentWasSent(isOutgoingPayment)) - } else { - PubkyContactAvatar(profile = displayContact, size = 40.dp) - } - Column(modifier = Modifier.weight(1f)) { - BodyMSB( - text = title ?: displayContact.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - BodyS( - text = subtitle, - color = Colors.White64, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + BoxWithConstraints(modifier = Modifier.padding(16.dp)) { + val amountMaxWidth = (maxWidth - 40.dp - 32.dp) / 2 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (request.showsPaymentRailIcon(isOutgoingPayment)) { + PaymentRailIcon(request = request, paymentWasSent = request.paymentWasSent(isOutgoingPayment)) + } else { + PubkyContactAvatar(profile = displayContact, size = 40.dp) + } + Column(modifier = Modifier.weight(1f)) { + BodyMSB( + text = title ?: displayContact.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodyS( + text = subtitle, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + PaykitAmountCell( + amount = request.amount, + prefix = amountPrefix, + showBitcoinSymbol = showBitcoinSymbol, + status = paymentRequestAmountStatus(request, receipt, fiatStatus), + maxAmountLines = 1, + modifier = Modifier.widthIn(max = amountMaxWidth) ) } - PaykitAmountCell( - amount = request.amount, - prefix = amountPrefix, - showBitcoinSymbol = showBitcoinSymbol, - status = paymentRequestAmountStatus(request, receipt, fiatStatus), - modifier = Modifier.weight(1f) - ) } if (onPay != null || onDismiss != null) { Row( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt index dc27c91d9d..a4bf548b04 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Job import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.repositories.Endpoint @@ -36,7 +37,7 @@ import to.bitkit.ui.theme.Colors fun SendContactSelectScreen( viewModel: SendContactSelectViewModel, onBack: () -> Unit, - onOpenPayment: (String, String, PrivatePaykitPaymentContext?, List) -> Unit, + onOpenPayment: (String, String, PrivatePaykitPaymentContext?, List) -> Job?, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -44,7 +45,9 @@ fun SendContactSelectScreen( viewModel.effects.collect { when (it) { is SendContactSelectEffect.OpenPayment -> - onOpenPayment(it.paymentRequest, it.publicKey, it.privatePaymentContext, it.endpoints) + viewModel.onPaymentOpening( + onOpenPayment(it.paymentRequest, it.publicKey, it.privatePaymentContext, it.endpoints) + ) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt index b92fb9dc1b..415a270379 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt @@ -9,6 +9,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -67,11 +68,13 @@ class SendContactSelectViewModel @Inject constructor( if (_uiState.value.resolvingPublicKey != null) return _uiState.update { it.copy(resolvingPublicKey = publicKey) } viewModelScope.launch { + var openingPayment = false try { privatePaykitRepo.beginSavedContactPayment(publicKey) .onSuccess { result -> when (result) { - is PublicPaykitPaymentResult.Opened -> + is PublicPaykitPaymentResult.Opened -> { + openingPayment = true _effects.emit( SendContactSelectEffect.OpenPayment( result.paymentRequest, @@ -80,6 +83,7 @@ class SendContactSelectViewModel @Inject constructor( result.endpoints, ) ) + } PublicPaykitPaymentResult.NoEndpoint -> showPayError(R.string.slashtags__error_pay_empty_msg) PublicPaykitPaymentResult.NotOpened -> @@ -94,6 +98,16 @@ class SendContactSelectViewModel @Inject constructor( Logger.warn("Failed to begin contact payment", it, context = TAG) showPayError(R.string.slashtags__error_pay_not_opened_msg) } + } finally { + if (!openingPayment) _uiState.update { it.copy(resolvingPublicKey = null) } + } + } + } + + fun onPaymentOpening(scanJob: Job?) { + viewModelScope.launch { + try { + scanJob?.join() } finally { _uiState.update { it.copy(resolvingPublicKey = null) } } 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 9d2841eea0..e36481de39 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 @@ -116,7 +116,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()) { + Column(modifier = Modifier.fillMaxSize().navigationBarsPadding()) { AppTopBar( titleText = stringResource( transfer.detailTitleResource @@ -179,7 +179,7 @@ internal fun UsdtActivityDetail( if (showDetails && (transfer.txHash != null || canOpenDeposits)) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.navigationBarsPadding().padding(16.dp) + modifier = Modifier.padding(16.dp) ) { if (canOpenDeposits) { PrimaryButton( diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModelTest.kt index fdf20f81a6..0f16f046b0 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModelTest.kt @@ -3,7 +3,11 @@ package to.bitkit.ui.screens.wallets.send import android.content.Context import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test import org.mockito.kotlin.any @@ -47,4 +51,38 @@ class SendContactSelectViewModelTest : BaseUnitTest() { verify(payments, times(2)).beginSavedContactPayment("alice") assertNull(model.uiState.value.resolvingPublicKey) } + + @Test + fun `contact stays busy through navigation and unlocks on completion or cancellation`() = test { + val context: Context = mock() + val contacts: PubkyRepo = mock() + val payments: PrivatePaykitRepo = mock() + whenever(context.getString(any())).thenReturn("") + whenever(contacts.contacts).thenReturn(MutableStateFlow(emptyList())) + whenever(contacts.isLoadingContacts).thenReturn(MutableStateFlow(false)) + whenever(payments.beginSavedContactPayment("alice")) + .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("payment"))) + val model = SendContactSelectViewModel(context, contacts, payments) + + for (cancel in listOf(false, true)) { + val received = CompletableDeferred() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { received.complete(model.effects.first()) } + model.payContact("alice") + advanceUntilIdle() + assertEquals("alice", (received.await() as SendContactSelectEffect.OpenPayment).publicKey) + model.payContact("bob") + assertEquals("alice", model.uiState.value.resolvingPublicKey) + + val navigation = Job() + model.onPaymentOpening(navigation) + advanceUntilIdle() + model.payContact("bob") + assertEquals("alice", model.uiState.value.resolvingPublicKey) + verify(payments, times(0)).beginSavedContactPayment("bob") + if (cancel) navigation.cancel() else navigation.complete() + advanceUntilIdle() + assertNull(model.uiState.value.resolvingPublicKey) + } + verify(payments, times(2)).beginSavedContactPayment("alice") + } } From 79d2089e3d371b5bedc7dacc17c65738211ae5ca Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 18 Oct 2026 20:03:57 -0600 Subject: [PATCH 4/8] fix: keep request amounts readable --- .../PaymentRequestsScreenTest.kt | 75 +++++++++++++++++++ .../java/to/bitkit/ui/components/Money.kt | 30 ++++++-- .../bitkit/ui/components/PaykitAmountView.kt | 13 +++- .../paymentrequests/PaymentRequestsScreen.kt | 5 +- 4 files changed, 111 insertions(+), 12 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index 76898136bb..4b79e60d09 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -29,6 +29,8 @@ import androidx.compose.ui.unit.dp import com.synonym.paykit.PaymentRequestLifecycleState import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf +import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import to.bitkit.models.PaykitAmount @@ -48,6 +50,7 @@ import to.bitkit.repositories.PaykitSubscriptionRecurrence import to.bitkit.test.annotations.ComposeUi import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.theme.AppThemeSurface +import java.util.Locale import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Clock @@ -56,9 +59,21 @@ import kotlin.time.Instant @ComposeUi class PaymentRequestsScreenTest { + private val originalLocale = Locale.getDefault() + @get:Rule val composeTestRule = createComposeRule() + @Before + fun setup() { + Locale.setDefault(Locale.US) + } + + @After + fun teardown() { + Locale.setDefault(originalLocale) + } + @Test fun detailsDisablePayWhilePreparationIsPending() { val isPreparing = mutableStateOf(false) @@ -363,6 +378,66 @@ class PaymentRequestsScreenTest { } } + @Test + fun longAmountUsesAvailableCardWidthAtLargeText() { + val asset = mutableStateOf(PaykitAsset.BTC) + composeTestRule.setContent { + PaymentRequestsTestSurface { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, 1.3f)) { + Box(Modifier.width(320.dp)) { + PaymentRequestCard( + request = request().copy(amount = PaykitAmount(asset.value, 1_000_000u)), + contact = PubkyProfile.forDisplay(request().counterparty, "Alice", imageUrl = null), + ) + } + } + } + } + for ((value, text) in listOf(PaykitAsset.BTC to "₿ 1 000 000", PaykitAsset.USD to "$10,000.00")) { + composeTestRule.runOnIdle { asset.value = value } + val results = mutableListOf() + composeTestRule.onNodeWithText(text).assertIsDisplayed() + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) } + assertTrue(results.isNotEmpty()) + assertFalse(results.single().hasVisualOverflow, "$text must fit without truncation") + assertTrue(results.single().lineCount == 1) + } + } + + @Test + fun bitcoinReceiptStatusUsesCaptionInBothUnitSettings() { + val primary = mutableStateOf(PrimaryDisplay.FIAT) + val status = "Płatność zweryfikowana · 4,12485 USDT" + composeTestRule.setContent { + PaymentRequestsTestSurface(CurrencyState(primaryDisplay = primary.value)) { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, 1.3f)) { + Box(Modifier.width(360.dp)) { + PaymentRequestCard( + request = request().copy(amount = PaykitAmount(PaykitAsset.BTC, 5_000u)), + contact = PubkyProfile.forDisplay(request().counterparty, "Alice", imageUrl = null), + fiatStatus = status, + ) + } + } + } + } + val layouts = PrimaryDisplay.entries.map { value -> + composeTestRule.runOnIdle { primary.value = value } + val results = mutableListOf() + composeTestRule.onNodeWithText(status).assertIsDisplayed() + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) } + results.single().also { layout -> + assertFalse(layout.hasVisualOverflow) + val wordStart = status.indexOf("zweryfikowana") + val wordEnd = wordStart + "zweryfikowana".lastIndex + assertTrue(layout.getLineForOffset(wordStart) == layout.getLineForOffset(wordEnd)) + } + } + assertTrue(layouts.first().layoutInput.style.fontSize == layouts.last().layoutInput.style.fontSize) + } + private fun setOutgoingRequestContent(primaryDisplay: PrimaryDisplay) { val outgoing = request(id = "outgoing").copy( createdAt = Clock.System.now(), diff --git a/app/src/main/java/to/bitkit/ui/components/Money.kt b/app/src/main/java/to/bitkit/ui/components/Money.kt index ba62c7d6c2..dab2aebd22 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -2,6 +2,7 @@ package to.bitkit.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -14,6 +15,7 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import to.bitkit.models.BITCOIN_SYMBOL @@ -62,6 +64,7 @@ fun MoneyCell( showBitcoinSymbol: Boolean = true, fiatReplacement: String? = null, maxAmountLines: Int = Int.MAX_VALUE, + maxStatusWidth: Dp = Dp.Unspecified, ) { val currencies = LocalCurrencies.current val primaryUnit = currencies.primaryDisplay @@ -93,13 +96,22 @@ fun MoneyCell( modifier = modifier, ) { primary?.let { text -> - BodyMSB( - text = (if (primaryIsReplaced) text else "$prefix$text").withAccent(accentColor = Colors.White64), - textAlign = TextAlign.End, - maxLines = if (primaryIsReplaced) Int.MAX_VALUE else maxAmountLines, - overflow = amountOverflow, - modifier = Modifier.testTag("MoneyPrimary"), - ) + if (primaryIsReplaced) { + CaptionB( + text = text, + color = Colors.White64, + textAlign = TextAlign.End, + modifier = Modifier.widthIn(max = maxStatusWidth).testTag("MoneyPrimary") + ) + } else { + BodyMSB( + text = "$prefix$text".withAccent(accentColor = Colors.White64), + textAlign = TextAlign.End, + maxLines = maxAmountLines, + overflow = amountOverflow, + modifier = Modifier.testTag("MoneyPrimary") + ) + } } secondary?.let { text -> CaptionB( @@ -108,7 +120,9 @@ fun MoneyCell( textAlign = TextAlign.End, maxLines = if (secondaryIsReplaced) Int.MAX_VALUE else maxAmountLines, overflow = amountOverflow, - modifier = Modifier.testTag("MoneySecondary"), + modifier = Modifier + .widthIn(max = if (secondaryIsReplaced) maxStatusWidth else Dp.Unspecified) + .testTag("MoneySecondary") ) } } diff --git a/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt b/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt index 2a473723b6..0cf923e88d 100644 --- a/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt +++ b/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt @@ -1,12 +1,14 @@ package to.bitkit.ui.components import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import to.bitkit.models.PaykitAmount import to.bitkit.models.PaykitAsset import to.bitkit.ui.theme.Colors @@ -48,6 +50,7 @@ fun PaykitAmountCell( status: String? = null, showBitcoinSymbol: Boolean = true, maxAmountLines: Int = Int.MAX_VALUE, + maxStatusWidth: Dp = Dp.Unspecified, ) { if (amount.asset == PaykitAsset.BTC) { MoneyCell( @@ -56,6 +59,7 @@ fun PaykitAmountCell( showBitcoinSymbol = showBitcoinSymbol, fiatReplacement = status, maxAmountLines = maxAmountLines, + maxStatusWidth = maxStatusWidth, modifier = modifier ) } else { @@ -73,7 +77,14 @@ fun PaykitAmountCell( maxLines = maxAmountLines, overflow = if (maxAmountLines == Int.MAX_VALUE) TextOverflow.Clip else TextOverflow.Ellipsis, ) - status?.let { BodyS(text = it, color = Colors.White64, textAlign = TextAlign.End) } + status?.let { + BodyS( + text = it, + color = Colors.White64, + textAlign = TextAlign.End, + modifier = Modifier.widthIn(max = maxStatusWidth) + ) + } } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index 1fc2d8a569..b95c61e4c8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card @@ -600,7 +599,7 @@ internal fun PaymentRequestCard( .testTag("PaymentRequestRow-${request.paymentRequestId}") ) { BoxWithConstraints(modifier = Modifier.padding(16.dp)) { - val amountMaxWidth = (maxWidth - 40.dp - 32.dp) / 2 + val statusMaxWidth = (maxWidth - 40.dp - 32.dp) / 2 Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -629,7 +628,7 @@ internal fun PaymentRequestCard( showBitcoinSymbol = showBitcoinSymbol, status = paymentRequestAmountStatus(request, receipt, fiatStatus), maxAmountLines = 1, - modifier = Modifier.widthIn(max = amountMaxWidth) + maxStatusWidth = statusMaxWidth, ) } } From 595951a0a78df6f25417efed314cd19e8205f508 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 04:24:57 -0600 Subject: [PATCH 5/8] fix: preserve receipt words in narrow request cards --- .../PaymentRequestsScreenTest.kt | 23 ++++--- .../java/to/bitkit/ui/components/Money.kt | 10 +-- .../bitkit/ui/components/PaykitAmountView.kt | 8 +-- .../paymentrequests/PaymentRequestsScreen.kt | 63 +++++++++---------- journeys/payment-requests/README.md | 5 ++ .../contact-request-or-pay.xml | 4 +- journeys/payment-requests/request-summary.xml | 4 +- journeys/usdt-send-review-and-details.xml | 2 +- 8 files changed, 63 insertions(+), 56 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index 4b79e60d09..1aebd00aec 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -51,6 +51,7 @@ import to.bitkit.test.annotations.ComposeUi import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.theme.AppThemeSurface import java.util.Locale +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Clock @@ -329,13 +330,14 @@ class PaymentRequestsScreenTest { @Test fun longReceiptStatusKeepsContactAndExactAmountReadable() { val status = "Płatność zweryfikowana · 0.001647 USDT" + val asset = mutableStateOf(PaykitAsset.USDT) composeTestRule.setContent { PaymentRequestsTestSurface { val density = LocalDensity.current CompositionLocalProvider(LocalDensity provides Density(density.density, 1.3f)) { Box(Modifier.width(320.dp)) { PaymentRequestCard( - request = request().copy(amount = PaykitAmount(PaykitAsset.USDT, 1_647u)), + request = request().copy(amount = PaykitAmount(asset.value, 1_647u)), contact = PubkyProfile.forDisplay(request().counterparty, "Alice", imageUrl = null), fiatStatus = status, ) @@ -343,12 +345,19 @@ class PaymentRequestsScreenTest { } } } - for (text in listOf("Alice", status)) { - val results = mutableListOf() - composeTestRule.onNodeWithText(text).assertIsDisplayed() - .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) } - assertTrue(results.isNotEmpty()) - assertFalse(results.single().hasVisualOverflow, "$text must fit without truncation") + for (denomination in listOf(PaykitAsset.USDT, PaykitAsset.BTC)) { + composeTestRule.runOnIdle { asset.value = denomination } + for (text in listOf("Alice", status)) { + val results = mutableListOf() + composeTestRule.onNodeWithText(text).assertIsDisplayed() + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) } + val layout = results.single() + assertFalse(layout.hasVisualOverflow, "$text must fit without truncation") + for (word in text.split(' ')) { + val start = text.indexOf(word) + assertEquals(layout.getLineForOffset(start), layout.getLineForOffset(start + word.length - 1)) + } + } } } diff --git a/app/src/main/java/to/bitkit/ui/components/Money.kt b/app/src/main/java/to/bitkit/ui/components/Money.kt index dab2aebd22..bcc7bc6a93 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -2,7 +2,6 @@ package to.bitkit.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -15,7 +14,6 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import to.bitkit.models.BITCOIN_SYMBOL @@ -64,7 +62,7 @@ fun MoneyCell( showBitcoinSymbol: Boolean = true, fiatReplacement: String? = null, maxAmountLines: Int = Int.MAX_VALUE, - maxStatusWidth: Dp = Dp.Unspecified, + statusModifier: Modifier = Modifier, ) { val currencies = LocalCurrencies.current val primaryUnit = currencies.primaryDisplay @@ -101,7 +99,7 @@ fun MoneyCell( text = text, color = Colors.White64, textAlign = TextAlign.End, - modifier = Modifier.widthIn(max = maxStatusWidth).testTag("MoneyPrimary") + modifier = statusModifier.testTag("MoneyPrimary") ) } else { BodyMSB( @@ -120,9 +118,7 @@ fun MoneyCell( textAlign = TextAlign.End, maxLines = if (secondaryIsReplaced) Int.MAX_VALUE else maxAmountLines, overflow = amountOverflow, - modifier = Modifier - .widthIn(max = if (secondaryIsReplaced) maxStatusWidth else Dp.Unspecified) - .testTag("MoneySecondary") + modifier = (if (secondaryIsReplaced) statusModifier else Modifier).testTag("MoneySecondary") ) } } diff --git a/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt b/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt index 0cf923e88d..2d3518f939 100644 --- a/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt +++ b/app/src/main/java/to/bitkit/ui/components/PaykitAmountView.kt @@ -1,14 +1,12 @@ package to.bitkit.ui.components import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp import to.bitkit.models.PaykitAmount import to.bitkit.models.PaykitAsset import to.bitkit.ui.theme.Colors @@ -50,7 +48,7 @@ fun PaykitAmountCell( status: String? = null, showBitcoinSymbol: Boolean = true, maxAmountLines: Int = Int.MAX_VALUE, - maxStatusWidth: Dp = Dp.Unspecified, + statusModifier: Modifier = Modifier, ) { if (amount.asset == PaykitAsset.BTC) { MoneyCell( @@ -59,7 +57,7 @@ fun PaykitAmountCell( showBitcoinSymbol = showBitcoinSymbol, fiatReplacement = status, maxAmountLines = maxAmountLines, - maxStatusWidth = maxStatusWidth, + statusModifier = statusModifier, modifier = modifier ) } else { @@ -82,7 +80,7 @@ fun PaykitAmountCell( text = it, color = Colors.White64, textAlign = TextAlign.End, - modifier = Modifier.widthIn(max = maxStatusWidth) + modifier = statusModifier ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index b95c61e4c8..647b67f481 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -8,8 +8,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border 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.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card @@ -598,39 +599,37 @@ internal fun PaymentRequestCard( .clickableAlpha(enabled = onClick != null) { onClick?.invoke() } .testTag("PaymentRequestRow-${request.paymentRequestId}") ) { - BoxWithConstraints(modifier = Modifier.padding(16.dp)) { - val statusMaxWidth = (maxWidth - 40.dp - 32.dp) / 2 - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - if (request.showsPaymentRailIcon(isOutgoingPayment)) { - PaymentRailIcon(request = request, paymentWasSent = request.paymentWasSent(isOutgoingPayment)) - } else { - PubkyContactAvatar(profile = displayContact, size = 40.dp) - } - Column(modifier = Modifier.weight(1f)) { - BodyMSB( - text = title ?: displayContact.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - BodyS( - text = subtitle, - color = Colors.White64, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - PaykitAmountCell( - amount = request.amount, - prefix = amountPrefix, - showBitcoinSymbol = showBitcoinSymbol, - status = paymentRequestAmountStatus(request, receipt, fiatStatus), - maxAmountLines = 1, - maxStatusWidth = statusMaxWidth, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(16.dp) + ) { + if (request.showsPaymentRailIcon(isOutgoingPayment)) { + PaymentRailIcon(request = request, paymentWasSent = request.paymentWasSent(isOutgoingPayment)) + } else { + PubkyContactAvatar(profile = displayContact, size = 40.dp) + } + Column(modifier = Modifier.weight(1f)) { + BodyMSB( + text = title ?: displayContact.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodyS( + text = subtitle, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } + PaykitAmountCell( + amount = request.amount, + prefix = amountPrefix, + showBitcoinSymbol = showBitcoinSymbol, + status = paymentRequestAmountStatus(request, receipt, fiatStatus), + maxAmountLines = 1, + statusModifier = Modifier.width(IntrinsicSize.Min), + ) } if (onPay != null || onDismiss != null) { Row( diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 0916655715..e3fda3aae3 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -47,6 +47,11 @@ existing foreground synchronization. A protocol proof changes the note-free subt "Proof submitted"; a nonblank note still takes precedence. This is not inferred from a chain payment. iOS keeps a static note/date receipt with no lifecycle subtitle, so these receipt-status steps do not apply there. +The USDT history step in `request-summary.xml` requires both instances opted in, a payer funded +with local test USDT, and two completed requests (one USD-denominated and one BTC-denominated), +each with a verified receipt for exactly 1.234567 USDT. Establish these using the local USDT +fixture before the journey. `contact-request-or-pay.xml` also needs a third linked contact that +publishes no payment endpoints, so the failure and retry steps are reproducible. `contact-request-or-pay.xml` uses the same two-instance setup and starts from the payer's Contact Detail screen, opened through the `bitkit://contact` deeplink. Its timing step assumes the payer has been running for about a minute: right after launch, the Paykit session restore and link refresh hold the SDK and can push the Pay step well past the budget. diff --git a/journeys/payment-requests/contact-request-or-pay.xml b/journeys/payment-requests/contact-request-or-pay.xml index cbf6dfcd44..1f15998980 100644 --- a/journeys/payment-requests/contact-request-or-pay.xml +++ b/journeys/payment-requests/contact-request-or-pay.xml @@ -1,6 +1,6 @@ - Verifies that Pay on a linked contact offers Request or Pay, that paying shows progress on the sheet until the amount screen opens within a few seconds, and that Request opens the Payment Request amount screen. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded; see README.md. Let the payer run for a minute after launch before starting, so start-up Paykit work does not dominate the timings. Open the contact with adb shell am start -a android.intent.action.VIEW -d "bitkit://contact?pubky=<requester-public-key>" to.bitkit.dev. + Verifies that Pay on a linked contact offers Request or Pay, that paying shows progress on the sheet until the amount screen opens within a few seconds, and that Request opens the Payment Request amount screen. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded; see README.md. Also save a third linked contact that publishes no payment endpoints, for the resolution-failure check. Let the payer run for a minute after launch before starting, so start-up Paykit work does not dominate the timings. Open the contact with adb shell am start -a android.intent.action.VIEW -d "bitkit://contact?pubky=<requester-public-key>" to.bitkit.dev. Open bitkit://contact?pubky=<requester-public-key> on the payer @@ -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. If resolution fails, verify the error is shown and contact selection becomes available for retry. Cancel without paying. + 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. diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index 491c8a8b96..961235e20e 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -1,6 +1,6 @@ - Verifies that the collapsed Payment Request confirmation shows who the request is from and what it is for, keeps the note in the details, and shows "Not specified" in For when the request has no note. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded to cover 21,000 sats; see README.md. Android also checks that the requester's open Sent receipt follows protocol history when no note is present. iOS keeps a static note/date receipt and does not display this lifecycle subtitle. + Verifies that the collapsed Payment Request confirmation shows who the request is from and what it is for, keeps the note in the details, and shows "Not specified" in For when the request has no note. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded to cover 21,000 sats plus fees and local test USDT payments. Before starting, use the local USDT fixture to establish two completed requests in the requester's history: one USD-denominated and one BTC-denominated, each with a verified receipt for exactly 1.234567 USDT. Both instances must have USDT enabled. See README.md. Android also checks that the requester's open Sent receipt follows protocol history when no note is present. iOS keeps a static note/date receipt and does not display this lifecycle subtitle. On the requester, send the payer contact a Payment Request for 21,000 sats with the note "Lunch last week" @@ -19,6 +19,6 @@ On the payer, wait for preparation to finish and swipe to pay the 5,000 sats request On the requester, close any received-payment sheet covering the Sent receipt, leaving the receipt open underneath Keep the requester foregrounded through its existing Paykit synchronization; after the protocol payment proof arrives, verify the same Sent receipt shows "Proof submitted" instead of "Waiting for payment", while its PAYMENT REQUESTED heading remains unchanged - Open existing USDT payment request history with a verified receipt containing an exact six-decimal amount. With enlarged text and a long translated verification status, verify the contact name stays readable and the complete status wraps within the amount column. Repeat for a BTC-denominated request paid in USDT. + Open the prepared USD-denominated request in payment history. With enlarged text and a long translated verification status, verify the contact name stays readable and the complete status wraps within the amount column. Verify the receipt status shows exactly 1.234567 USDT, using the selected locale's decimal separator. Repeat for the prepared BTC-denominated request paid in USDT. diff --git a/journeys/usdt-send-review-and-details.xml b/journeys/usdt-send-review-and-details.xml index b5af303ab4..9b4021e319 100644 --- a/journeys/usdt-send-review-and-details.xml +++ b/journeys/usdt-send-review-and-details.xml @@ -1,5 +1,5 @@ - Use an onboarded wallet with USDT, a fresh BTC/USD rate, an owned recipient and existing sent/received activity. Quotes require the service with MAX support. No payment is submitted. + Use an onboarded wallet with USDT, a fresh BTC/USD rate, owned EVM and Solana recipients and existing sent/received activity. Save a linked contact that advertises Bitcoin, Lightning and USDT receiving methods. Have an unpaid one-time request and a due subscription payment from that contact, each permitting USDT. Pair a hardware wallet for the hardware-option check. Use the local USDT fixture for funding, controlled quotes and submitted-payment states. Quotes require the service with MAX support. No payment is submitted. Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. Open USDT Send and enter the owned recipient. On Amount, verify the BTC equivalent above the dollar amount, 32-point gap before Available balance, USDT badge, MAX and network pill. Enter a six-decimal amount and verify no precision is lost. With a large available balance and BNB Smart Chain selected, verify both values remain fully readable; the controls may wrap onto a second row. From 140c2b9172e44d581e22b307d4bf5591005e3a03 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 05:54:59 -0600 Subject: [PATCH 6/8] fix: keep request contacts and receipts readable --- .../PaymentRequestsScreenTest.kt | 37 ++++++++------ .../paymentrequests/PaymentRequestsScreen.kt | 49 +++++++++++-------- journeys/payment-requests/request-summary.xml | 2 +- 3 files changed, 50 insertions(+), 38 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index 1aebd00aec..02c13d0cd2 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.screens.paymentrequests import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -331,31 +332,35 @@ class PaymentRequestsScreenTest { fun longReceiptStatusKeepsContactAndExactAmountReadable() { val status = "Płatność zweryfikowana · 0.001647 USDT" val asset = mutableStateOf(PaykitAsset.USDT) + val fontScale = mutableStateOf(1.3f) composeTestRule.setContent { PaymentRequestsTestSurface { val density = LocalDensity.current - CompositionLocalProvider(LocalDensity provides Density(density.density, 1.3f)) { - Box(Modifier.width(320.dp)) { + CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale.value)) { + Box(Modifier.width(320.dp).padding(horizontal = 16.dp)) { PaymentRequestCard( request = request().copy(amount = PaykitAmount(asset.value, 1_647u)), - contact = PubkyProfile.forDisplay(request().counterparty, "Alice", imageUrl = null), + contact = PubkyProfile.forDisplay(request().counterparty, "Maria", imageUrl = null), fiatStatus = status, ) } } } } - for (denomination in listOf(PaykitAsset.USDT, PaykitAsset.BTC)) { - composeTestRule.runOnIdle { asset.value = denomination } - for (text in listOf("Alice", status)) { - val results = mutableListOf() - composeTestRule.onNodeWithText(text).assertIsDisplayed() - .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) } - val layout = results.single() - assertFalse(layout.hasVisualOverflow, "$text must fit without truncation") - for (word in text.split(' ')) { - val start = text.indexOf(word) - assertEquals(layout.getLineForOffset(start), layout.getLineForOffset(start + word.length - 1)) + for (scale in listOf(1.3f, 2f)) { + composeTestRule.runOnIdle { fontScale.value = scale } + for (denomination in listOf(PaykitAsset.USDT, PaykitAsset.USD, PaykitAsset.BTC)) { + composeTestRule.runOnIdle { asset.value = denomination } + for (text in listOf("Maria", status)) { + val results = mutableListOf() + composeTestRule.onNodeWithText(text).assertIsDisplayed() + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) } + val layout = results.single() + assertFalse(layout.hasVisualOverflow, "$text must fit without truncation") + for (word in text.split(' ')) { + val start = text.indexOf(word) + assertEquals(layout.getLineForOffset(start), layout.getLineForOffset(start + word.length - 1)) + } } } } @@ -368,7 +373,7 @@ class PaymentRequestsScreenTest { PaymentRequestsTestSurface { val density = LocalDensity.current CompositionLocalProvider(LocalDensity provides Density(density.density, 1.3f)) { - Box(Modifier.width(375.dp)) { + Box(Modifier.width(375.dp).padding(horizontal = 16.dp)) { PaymentRequestCard( request = request().copy(amount = PaykitAmount(PaykitAsset.USD, 200u)), contact = PubkyProfile.forDisplay(request().counterparty, name, imageUrl = null), @@ -394,7 +399,7 @@ class PaymentRequestsScreenTest { PaymentRequestsTestSurface { val density = LocalDensity.current CompositionLocalProvider(LocalDensity provides Density(density.density, 1.3f)) { - Box(Modifier.width(320.dp)) { + Box(Modifier.width(320.dp).padding(horizontal = 16.dp)) { PaymentRequestCard( request = request().copy(amount = PaykitAmount(asset.value, 1_000_000u)), contact = PubkyProfile.forDisplay(request().counterparty, "Alice", imageUrl = null), diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index 647b67f481..f6c515d0c1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement 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.PaddingValues import androidx.compose.foundation.layout.Row @@ -599,28 +600,34 @@ internal fun PaymentRequestCard( .clickableAlpha(enabled = onClick != null) { onClick?.invoke() } .testTag("PaymentRequestRow-${request.paymentRequestId}") ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), + FlowRow( + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.End), + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(16.dp) ) { - if (request.showsPaymentRailIcon(isOutgoingPayment)) { - PaymentRailIcon(request = request, paymentWasSent = request.paymentWasSent(isOutgoingPayment)) - } else { - PubkyContactAvatar(profile = displayContact, size = 40.dp) - } - Column(modifier = Modifier.weight(1f)) { - BodyMSB( - text = title ?: displayContact.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - BodyS( - text = subtitle, - color = Colors.White64, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.weight(1f).width(IntrinsicSize.Min).align(Alignment.CenterVertically) + ) { + if (request.showsPaymentRailIcon(isOutgoingPayment)) { + PaymentRailIcon(request = request, paymentWasSent = request.paymentWasSent(isOutgoingPayment)) + } else { + PubkyContactAvatar(profile = displayContact, size = 40.dp) + } + Column(modifier = Modifier.weight(1f)) { + BodyMSB( + text = title ?: displayContact.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodyS( + text = subtitle, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } PaykitAmountCell( amount = request.amount, @@ -628,7 +635,7 @@ internal fun PaymentRequestCard( showBitcoinSymbol = showBitcoinSymbol, status = paymentRequestAmountStatus(request, receipt, fiatStatus), maxAmountLines = 1, - statusModifier = Modifier.width(IntrinsicSize.Min), + statusModifier = Modifier.width(IntrinsicSize.Max), ) } if (onPay != null || onDismiss != null) { diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index 961235e20e..123b9637ca 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -19,6 +19,6 @@ On the payer, wait for preparation to finish and swipe to pay the 5,000 sats request On the requester, close any received-payment sheet covering the Sent receipt, leaving the receipt open underneath Keep the requester foregrounded through its existing Paykit synchronization; after the protocol payment proof arrives, verify the same Sent receipt shows "Proof submitted" instead of "Waiting for payment", while its PAYMENT REQUESTED heading remains unchanged - Open the prepared USD-denominated request in payment history. With enlarged text and a long translated verification status, verify the contact name stays readable and the complete status wraps within the amount column. Verify the receipt status shows exactly 1.234567 USDT, using the selected locale's decimal separator. Repeat for the prepared BTC-denominated request paid in USDT. + Open the prepared USD-denominated request in payment history. On a 320-point/dp-wide screen with enlarged text and a long translated verification status, verify a short contact name such as Maria and every status word remain complete. The amount and receipt may move below the contact when needed. Repeat at the default text size and verify short amounts and statuses remain beside the contact when they fit. Verify the receipt status shows exactly 1.234567 USDT, using the selected locale's decimal separator. Repeat for the prepared BTC-denominated request paid in USDT. From 24b29f5d048dd21dc5faad24f29c0bae368efb01 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 19 Oct 2026 11:39:46 -0600 Subject: [PATCH 7/8] docs: make USDT receipt journeys reproducible --- journeys/payment-requests/README.md | 12 +++++++----- journeys/payment-requests/request-summary.xml | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index e3fda3aae3..c1abe230ee 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -47,11 +47,13 @@ existing foreground synchronization. A protocol proof changes the note-free subt "Proof submitted"; a nonblank note still takes precedence. This is not inferred from a chain payment. iOS keeps a static note/date receipt with no lifecycle subtitle, so these receipt-status steps do not apply there. -The USDT history step in `request-summary.xml` requires both instances opted in, a payer funded -with local test USDT, and two completed requests (one USD-denominated and one BTC-denominated), -each with a verified receipt for exactly 1.234567 USDT. Establish these using the local USDT -fixture before the journey. `contact-request-or-pay.xml` also needs a third linked contact that -publishes no payment endpoints, so the failure and retry steps are reproducible. +The USDT history step in `request-summary.xml` requires both instances opted in, a payer funded with +local test USDT, and two completed requests (one USD-denominated and one BTC-denominated), each paid +in USDT with a verified receipt. Establish these using the local USDT fixture before the journey and +record each payment's exact USDT amount from the payer's activity. Compare the receipt with that +amount, preserving all its decimals; the two payments need not have equal amounts. `contact-request- +or-pay.xml` also needs a third linked contact that publishes no payment endpoints, so the failure +and retry steps are reproducible. `contact-request-or-pay.xml` uses the same two-instance setup and starts from the payer's Contact Detail screen, opened through the `bitkit://contact` deeplink. Its timing step assumes the payer has been running for about a minute: right after launch, the Paykit session restore and link refresh hold the SDK and can push the Pay step well past the budget. diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index 123b9637ca..2b1b977f1b 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -1,6 +1,6 @@ - Verifies that the collapsed Payment Request confirmation shows who the request is from and what it is for, keeps the note in the details, and shows "Not specified" in For when the request has no note. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded to cover 21,000 sats plus fees and local test USDT payments. Before starting, use the local USDT fixture to establish two completed requests in the requester's history: one USD-denominated and one BTC-denominated, each with a verified receipt for exactly 1.234567 USDT. Both instances must have USDT enabled. See README.md. Android also checks that the requester's open Sent receipt follows protocol history when no note is present. iOS keeps a static note/date receipt and does not display this lifecycle subtitle. + Verifies that the collapsed Payment Request confirmation shows who the request is from and what it is for, keeps the note in the details, and shows "Not specified" in For when the request has no note. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded to cover 21,000 sats plus fees and local test USDT payments. Before starting, use the local USDT fixture to establish two completed requests in the requester's history: one USD-denominated and one BTC-denominated, each paid in USDT with a verified receipt. Record each payment's exact USDT amount from the payer's activity for comparison; the amounts need not be equal. Both instances must have USDT enabled. See README.md. Android also checks that the requester's open Sent receipt follows protocol history when no note is present. iOS keeps a static note/date receipt and does not display this lifecycle subtitle. On the requester, send the payer contact a Payment Request for 21,000 sats with the note "Lunch last week" @@ -19,6 +19,6 @@ On the payer, wait for preparation to finish and swipe to pay the 5,000 sats request On the requester, close any received-payment sheet covering the Sent receipt, leaving the receipt open underneath Keep the requester foregrounded through its existing Paykit synchronization; after the protocol payment proof arrives, verify the same Sent receipt shows "Proof submitted" instead of "Waiting for payment", while its PAYMENT REQUESTED heading remains unchanged - Open the prepared USD-denominated request in payment history. On a 320-point/dp-wide screen with enlarged text and a long translated verification status, verify a short contact name such as Maria and every status word remain complete. The amount and receipt may move below the contact when needed. Repeat at the default text size and verify short amounts and statuses remain beside the contact when they fit. Verify the receipt status shows exactly 1.234567 USDT, using the selected locale's decimal separator. Repeat for the prepared BTC-denominated request paid in USDT. + Open the prepared USD-denominated request in payment history. On a 320-dp-wide Android screen with enlarged text and a long translated verification status, verify a short contact name such as Maria and every status word remain complete. The amount and receipt may move below the contact when needed. Repeat at the default text size and verify short amounts and statuses remain beside the contact when they fit. Verify the receipt status shows the paid USDT amount with all its decimals, matching the payer's activity and using the selected locale's decimal separator. Repeat for the prepared BTC-denominated request paid in USDT. From afcf6ff4ec512020e10934e7b346df3ca004e1ee Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 20 Oct 2026 06:09:16 -0600 Subject: [PATCH 8/8] fix: preserve contact opening and receipt names --- .../PaymentRequestsScreenTest.kt | 28 +++++++++++++++++++ .../paymentrequests/PaymentRequestsScreen.kt | 2 +- .../send/SendContactSelectViewModel.kt | 12 ++++---- .../send/SendContactSelectViewModelTest.kt | 11 +++++++- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index 02c13d0cd2..03aa40e50f 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -366,6 +366,34 @@ class PaymentRequestsScreenTest { } } + @Test + fun shortWordContactKeepsFullNameBesideLongReceipt() { + val name = "Ann Lee" + val status = "Payment verified · 0.001647 USDT" + val width = mutableStateOf(407.dp) + composeTestRule.setContent { + PaymentRequestsTestSurface { + Box(Modifier.width(width.value).padding(horizontal = 16.dp)) { + PaymentRequestCard( + request = request().copy(amount = PaykitAmount(PaykitAsset.USDT, 1_647u)), + contact = PubkyProfile.forDisplay(request().counterparty, name, imageUrl = null), + compactSubtitle = "09:41", + fiatStatus = status, + ) + } + } + } + for (size in listOf(407.dp, 412.dp)) { + composeTestRule.runOnIdle { width.value = size } + for (text in listOf(name, status)) { + val results = mutableListOf() + composeTestRule.onNodeWithText(text).assertIsDisplayed() + .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) } + assertFalse(results.single().hasVisualOverflow, "$text must fit without truncation at $size") + } + } + } + @Test fun shortAmountLeavesRoomForContactNameAtLargeText() { val name = "Alexandra Payer" diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index 8ea7365ce4..619dbd81ed 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -608,7 +608,7 @@ internal fun PaymentRequestCard( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.weight(1f).width(IntrinsicSize.Min).align(Alignment.CenterVertically) + modifier = Modifier.weight(1f).width(IntrinsicSize.Max).align(Alignment.CenterVertically) ) { if (request.showsPaymentRailIcon(isOutgoingPayment)) { PaymentRailIcon(request = request, paymentWasSent = request.paymentWasSent(isOutgoingPayment)) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt index 415a270379..8aac9e0542 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt @@ -10,11 +10,10 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R @@ -38,8 +37,8 @@ class SendContactSelectViewModel @Inject constructor( private val _uiState = MutableStateFlow(SendContactSelectUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private val _effects = MutableSharedFlow(extraBufferCapacity = 1) - val effects = _effects.asSharedFlow() + private val pendingPayment = MutableStateFlow(null) + val effects = pendingPayment.filterNotNull() init { viewModelScope.launch { @@ -75,14 +74,14 @@ class SendContactSelectViewModel @Inject constructor( when (result) { is PublicPaykitPaymentResult.Opened -> { openingPayment = true - _effects.emit( + pendingPayment.update { SendContactSelectEffect.OpenPayment( result.paymentRequest, publicKey, result.privatePaymentContext, result.endpoints, ) - ) + } } PublicPaykitPaymentResult.NoEndpoint -> showPayError(R.string.slashtags__error_pay_empty_msg) @@ -105,6 +104,7 @@ class SendContactSelectViewModel @Inject constructor( } fun onPaymentOpening(scanJob: Job?) { + pendingPayment.update { null } viewModelScope.launch { try { scanJob?.join() diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModelTest.kt index 0f16f046b0..9f45c31d92 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModelTest.kt @@ -21,7 +21,9 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class SendContactSelectViewModelTest : BaseUnitTest() { @@ -66,9 +68,10 @@ class SendContactSelectViewModelTest : BaseUnitTest() { for (cancel in listOf(false, true)) { val received = CompletableDeferred() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { received.complete(model.effects.first()) } model.payContact("alice") advanceUntilIdle() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { received.complete(model.effects.first()) } + assertTrue(received.isCompleted, "Opening must survive a gap between screen collectors") assertEquals("alice", (received.await() as SendContactSelectEffect.OpenPayment).publicKey) model.payContact("bob") assertEquals("alice", model.uiState.value.resolvingPublicKey) @@ -76,6 +79,12 @@ class SendContactSelectViewModelTest : BaseUnitTest() { val navigation = Job() model.onPaymentOpening(navigation) advanceUntilIdle() + val reopened = CompletableDeferred() + val collector = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + reopened.complete(model.effects.first()) + } + assertFalse(reopened.isCompleted, "Recreating the screen must not open the same payment twice") + collector.cancel() model.payContact("bob") assertEquals("alice", model.uiState.value.resolvingPublicKey) verify(payments, times(0)).beginSavedContactPayment("bob")