diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index 42f259aa8e..bfdb65894d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -1,8 +1,10 @@ package to.bitkit.ui.screens.wallets.send +import androidx.annotation.DrawableRes import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.tween import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -35,6 +37,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag @@ -69,6 +72,7 @@ import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.FillWidth import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.NumberPadActionButton import to.bitkit.ui.components.PrimaryButton @@ -78,7 +82,9 @@ import to.bitkit.ui.components.SwipeToConfirm import to.bitkit.ui.components.SyncNodeView import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.Title import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.ZigzagDivider import to.bitkit.ui.components.rememberMoneyText import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.settingsViewModel @@ -338,7 +344,7 @@ private fun ContentRunning( .testTag("ReviewAmount") ) - VerticalSpacer(44.dp) + VerticalSpacer(if (uiState.isOneOffPaymentRequest && !isLnurlPay) 24.dp else 44.dp) if (isLnurlPay) { LnurlPayDetails(uiState = uiState, onEvent = onEvent) @@ -352,6 +358,10 @@ private fun ContentRunning( ) VerticalSpacer(16.dp) TagsSection(uiState, onClickTag, onClickAddTag) + uiState.oneOffPaymentRequestNote?.let { + VerticalSpacer(16.dp) + PaymentRequestInvoiceNote(note = it) + } } SendMethod.LIGHTNING -> { @@ -364,6 +374,10 @@ private fun ContentRunning( } } } else { + if (uiState.isOneOffPaymentRequest) { + PaymentRequestSummary(uiState = uiState, iconColor = accentColor) + VerticalSpacer(16.dp) + } Image( painter = painterResource(R.drawable.coin_stack_4), contentDescription = null, @@ -523,7 +537,13 @@ private fun OnChainDetails( ) } SendCell( - caption = stringResource(R.string.wallet__send_to), + caption = stringResource( + if (uiState.isOneOffPaymentRequest) { + R.string.wallet__payment_request_contact + } else { + R.string.wallet__send_to + } + ), modifier = Modifier.weight(1f) ) { if (uiState.contactPaymentProfile != null) { @@ -661,7 +681,13 @@ private fun LightningDetails( ) } SendCell( - caption = stringResource(R.string.wallet__send_to), + caption = stringResource( + if (uiState.isOneOffPaymentRequest) { + R.string.wallet__payment_request_contact + } else { + R.string.wallet__send_to + } + ), modifier = Modifier.weight(1f) ) { if (uiState.contactPaymentProfile != null) { @@ -757,7 +783,7 @@ private fun LightningDetails( } } - if (!isLnurlPay && !description.isNullOrEmpty()) { + if (!isLnurlPay && !description.isNullOrEmpty() && description.trim() != uiState.oneOffPaymentRequestNote) { SendCell(caption = stringResource(R.string.wallet__note)) { Row(modifier = Modifier.horizontalScroll(rememberScrollState())) { BodySSB(text = description, maxLines = 1) @@ -772,6 +798,10 @@ private fun LightningDetails( onClickAddTag = onClickAddTag, ) } + + uiState.oneOffPaymentRequestNote?.let { + PaymentRequestInvoiceNote(note = it) + } } } @@ -796,6 +826,103 @@ private fun ContactRecipient( } } +@Composable +private fun PaymentRequestSummary( + uiState: SendUiState, + iconColor: Color, + modifier: Modifier = Modifier, +) { + val profile = uiState.contactPaymentProfile ?: return + val note = uiState.oneOffPaymentRequestNote + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier.height(IntrinsicSize.Min) + ) { + SendCell( + caption = stringResource(R.string.wallet__send_from), + modifier = Modifier.weight(1f) + ) { + PaymentRequestSummaryValue( + text = profile.name, + icon = R.drawable.ic_user, + iconColor = iconColor, + testTag = "PaymentRequestFrom", + ) + } + if (note != null) { + SendCell( + caption = stringResource(R.string.wallet__payment_request_for), + modifier = Modifier.weight(1f) + ) { + PaymentRequestSummaryValue( + text = note, + icon = R.drawable.ic_note, + iconColor = iconColor, + testTag = "PaymentRequestFor", + ) + } + } else { + FillWidth() + } + } +} + +@Composable +private fun PaymentRequestSummaryValue( + text: String, + @DrawableRes icon: Int, + iconColor: Color, + testTag: String, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(16.dp) + ) + BodySSB( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag(testTag) + ) + } +} + +@Composable +private fun PaymentRequestInvoiceNote( + note: String, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Caption13Up(text = stringResource(R.string.wallet__activity_invoice_note), color = Colors.White64) + VerticalSpacer(8.dp) + ZigzagDivider() + Title( + text = note, + color = Colors.White, + modifier = Modifier + .fillMaxWidth() + .background(Colors.White10) + .padding(24.dp) + .testTag("PaymentRequestInvoiceNote") + ) + } +} + +private val SendUiState.isOneOffPaymentRequest: Boolean + get() = isPaymentRequest && !isSubscriptionPayment + +private val SendUiState.oneOffPaymentRequestNote: String? + get() = paymentRequestNote?.trim()?.takeIf { isOneOffPaymentRequest && it.isNotEmpty() } + @Composable private fun LnurlPayDetails( uiState: SendUiState, @@ -1007,6 +1134,80 @@ private fun PreviewLightning() { } } +@Suppress("MagicNumber") +@Preview(showSystemUi = true, group = "payment request") +@Composable +private fun PreviewPaymentRequest() { + AppThemeSurface { + BottomSheetPreview { + SendConfirmContent( + uiState = sendUiState().copy( + amount = 21_000u, + payMethod = SendMethod.LIGHTNING, + isPaymentRequest = true, + contactPaymentProfile = PubkyProfile.placeholder("pk8e3xqyn5ha6swnhwp4bcw4dkj").copy( + name = "Anna Pleb", + ), + paymentRequestNote = "Lunch last week", + ), + isNodeRunning = true, + isLoading = false, + showBiometrics = false, + modifier = Modifier.sheetHeight(), + ) + } + } +} + +@Suppress("MagicNumber") +@Preview(showSystemUi = true, group = "payment request") +@Composable +private fun PreviewPaymentRequestDetails() { + AppThemeSurface { + BottomSheetPreview { + SendConfirmContent( + uiState = sendUiState().copy( + amount = 21_000u, + payMethod = SendMethod.LIGHTNING, + lightningFeeSats = 1, + isPaymentRequest = true, + contactPaymentProfile = PubkyProfile.placeholder("pk8e3xqyn5ha6swnhwp4bcw4dkj").copy( + name = "Areem Holden", + ), + paymentRequestNote = "Lunch last week", + selectedTags = persistentListOf("Lunch"), + ), + isNodeRunning = true, + isLoading = false, + showBiometrics = false, + initialShowDetails = true, + modifier = Modifier.sheetHeight(), + ) + } + } +} + +@Suppress("MagicNumber") +@Preview(showSystemUi = true, group = "payment request") +@Composable +private fun PreviewPaymentRequestWithoutNote() { + AppThemeSurface { + BottomSheetPreview { + SendConfirmContent( + uiState = sendUiState().copy( + amount = 21_000u, + isPaymentRequest = true, + contactPaymentProfile = PubkyProfile.placeholder("pk8e3xqyn5ha6swnhwp4bcw4dkj"), + ), + isNodeRunning = true, + isLoading = false, + showBiometrics = false, + modifier = Modifier.sheetHeight(), + ) + } + } +} + @Preview(showSystemUi = true) @Composable private fun PreviewLnurl() { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 8c4581b76b..bbfee5fe6d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2889,6 +2889,7 @@ class AppViewModel @Inject constructor( resetSendState( contactPaymentProfile = contactPaymentProfile, isPaymentRequest = isPaymentRequest, + paymentRequestNote = incomingPaymentRequest?.note, isSubscriptionPayment = incomingPaymentRequest?.billingPeriod != null, isInitialSubscriptionPayment = synchronized(contactPaymentContextLock) { activeContactPaymentContext?.isInitialSubscriptionPayment == true @@ -4645,6 +4646,7 @@ class AppViewModel @Inject constructor( suspend fun resetSendState( contactPaymentProfile: PubkyProfile? = null, isPaymentRequest: Boolean = false, + paymentRequestNote: String? = null, hardwareWalletId: String? = activeHardwareWalletId, isSubscriptionPayment: Boolean = false, isInitialSubscriptionPayment: Boolean = false, @@ -4666,6 +4668,7 @@ class AppViewModel @Inject constructor( onchainFeeUi = OnchainFeeUi(rate = FeeRate.fromSpeed(speed)), contactPaymentProfile = contactPaymentProfile, isPaymentRequest = isPaymentRequest, + paymentRequestNote = paymentRequestNote, hardwareWalletId = hardwareWalletId, hardwareWalletName = hardwareWalletId?.let { walletId -> hwWalletRepo.wallets.value.find { it.id == walletId }?.name @@ -5831,6 +5834,7 @@ data class SendUiState( val lastLightningFee: Long = 0L, val contactPaymentProfile: PubkyProfile? = null, val isPaymentRequest: Boolean = false, + val paymentRequestNote: String? = null, val hardwareWalletId: String? = null, val hardwareWalletName: String? = null, val hardwareAvailableSats: ULong = 0uL, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7aed66440d..4efb27a6af 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1324,6 +1324,7 @@ 1 hour 1 month 1 week + For The payment details did not match the request. Payment cancelled. Note What is this payment for? diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 148c33909b..bb58b3f733 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -870,6 +870,37 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) } + @Test + fun `opened request passes its note to the confirm sheet`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest().copy(note = "Lunch last week") + val bolt11 = "lnbcrt1requestwithnote" + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = bolt11, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + ), + ), + ) + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(request) + surfacedPaykitPaymentRequestIds += request.id + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertTrue(sut.sendUiState.value.isPaymentRequest) + assertEquals("Lunch last week", sut.sendUiState.value.paymentRequestNote) + } + @Test fun `manually reopened request preserves tags while waiting for a newer private list`() = test { sut.setIsAuthenticated(true) diff --git a/changelog.d/next/1336.changed.md b/changelog.d/next/1336.changed.md new file mode 100644 index 0000000000..e6bfef0a60 --- /dev/null +++ b/changelog.d/next/1336.changed.md @@ -0,0 +1 @@ +Incoming Payment Requests now show who the request is from and what it is for under the amount. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 750a7132a0..a4f2c609b4 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -18,6 +18,8 @@ The accepted journey uses: Rejected fixture shapes stay in unit tests because Bitkit intentionally does not present requests that fail the contract gate. +`request-summary.xml` uses a second Bitkit instance as the requester instead of the fixture issuer: both instances are authenticated Pubky identities, saved as each other's contacts and linked on receiver path `bitkit/wallet`, and the payer holds enough balance to pay 21,000 sats. + ## Reference evidence The source wallet-leg run completed this path on regtest on 2026-08-22: Bitkit presented the incoming request, opened the on-chain payment, broadcast it, and confirmed transaction @@ -33,6 +35,8 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, - Pay action: `PaymentRequestPay-` - Dismiss action: `PaymentRequestDismiss-` - Payment confirmation: `PaymentRequestConfirm` +- Confirmation summary: `PaymentRequestFrom`, `PaymentRequestFor` +- Confirmation invoice note: `PaymentRequestInvoiceNote` - Confirmation details: `SendConfirmToggleDetails` - Saved-contact recipient: `ReviewContactRecipient` diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml new file mode 100644 index 0000000000..fe8098e8d6 --- /dev/null +++ b/journeys/payment-requests/request-summary.xml @@ -0,0 +1,18 @@ + + + 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 leaves For out when the request has no note. Requires two Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet", with the payer funded to cover 21,000 sats; see README.md. + + + On the requester, send the payer contact a Payment Request for 21,000 sats with the note "Lunch last week" + On the payer, verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") appears with 21,000 sats + Verify From (testTag "PaymentRequestFrom") shows the requester's contact name + Verify For (testTag "PaymentRequestFor") shows "Lunch last week" + Tap Show details (testTag "SendConfirmToggleDetails") + Verify From (testTag "PaymentRequestFrom") and For (testTag "PaymentRequestFor") are no longer shown, and the recipient (testTag "ReviewContactRecipient") under Contact is the requester's contact + Verify the invoice note (testTag "PaymentRequestInvoiceNote") shows "Lunch last week" + Close the Payment Request confirmation screen without paying + On the requester, send the payer contact a Payment Request for 5,000 sats with no note + On the payer, verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") shows 5,000 sats, opening it from the pending Payment Requests bell (testTag "PaymentRequestsBell") if it does not open by itself + Verify From (testTag "PaymentRequestFrom") shows the requester's contact name and For (testTag "PaymentRequestFor") is not shown + +