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
+
+