diff --git a/.maestro/payment-request/payment-request.yaml b/.maestro/payment-request/payment-request.yaml new file mode 100644 index 0000000000..11fa1aed4f --- /dev/null +++ b/.maestro/payment-request/payment-request.yaml @@ -0,0 +1,177 @@ +appId: to.bitkit.dev +name: payment request figma frames +tags: + - visual +--- +- launchApp: + clearState: true +- runFlow: ../setup/onboard-new-wallet.yaml + +# seed paykit contacts, incoming and outgoing requests (debug builds only) +- openLink: bitkit://dev-fixture/payment-request +- extendedWaitUntil: + visible: + id: "TotalBalance-primary" + timeout: 10000 + +# list (payments tab of the subscriptions screen) +- tapOn: + id: "HeaderMenu" +- tapOn: + id: "DrawerSubscriptions" +- extendedWaitUntil: + visible: + id: "SubscriptionsScreen" + timeout: 10000 +- tapOn: "Payments" +- extendedWaitUntil: + visible: "Areem Holden" + timeout: 10000 +- assertVisible: "Ben" +- assertVisible: "Anna Pleb" +- assertVisible: "Snacks at conference" +- assertVisible: "John Carvalho" +- assertVisible: "Steaks & Burgers" +- assertVisible: + id: "PaymentRequestCreate" +- takeScreenshot: + path: list-after + +# recipient with contacts header, entered from the list +- tapOn: + id: "PaymentRequestCreate" +- extendedWaitUntil: + visible: + id: "PaymentRequestRecipientSearch" + timeout: 10000 +- extendedWaitUntil: + visible: "Alex Stronghand" + timeout: 5000 +- assertNotVisible: + id: "PaymentRequestContactsHeader" +- assertVisible: "Anna Pleb" +- assertVisible: "Areem Holden" +- assertVisible: "Craig Wrong" +- assertVisible: "John Carvalho" +- takeScreenshot: + path: recipient-after + +# amount +- tapOn: + id: "PaymentRequestContactpubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" +- extendedWaitUntil: + visible: + id: "PaymentRequestNumberPad" + timeout: 10000 +- assertVisible: + id: "PaymentRequestAmountUnit" +- takeScreenshot: + path: amount-empty-after +- tapOn: + id: "N5" +- tapOn: + id: "N0" +- tapOn: + id: "N0" +- tapOn: + id: "N0" +- tapOn: + id: "N0" +- assertVisible: "50 000" +- takeScreenshot: + path: amount-filled-after +- repeat: + times: 5 + commands: + - tapOn: + id: "NRemove" +- tapOn: + id: "N1" +- tapOn: + id: "N4" +- tapOn: + id: "N5" +- tapOn: + id: "N0" +- tapOn: + id: "N0" +- tapOn: + id: "PaymentRequestAmountContinue" + +# details +- extendedWaitUntil: + visible: + id: "PaymentRequestNote" + timeout: 5000 +- tapOn: + id: "PaymentRequestNote" +- inputText: "Snacks at conference" +- hideKeyboard +- assertVisible: ".*14 500" +- assertVisible: "Anna Pleb" +- assertVisible: "1 week" +- assertVisible: + id: "PaymentRequestSend" +- takeScreenshot: + path: details-after + +# sent +- tapOn: + id: "PaymentRequestSend" +- extendedWaitUntil: + visible: + id: "PaymentRequestSentCheck" + timeout: 10000 +- assertVisible: "Anna Pleb" +- assertVisible: "Snacks at conference" +- takeScreenshot: + path: sent-after +- tapOn: "OK" +- extendedWaitUntil: + notVisible: + id: "ReceiveScreen" + timeout: 5000 +- runFlow: + when: + visible: + id: "SubscriptionsScreen" + commands: + - tapOn: + id: "NavigationBack" +- extendedWaitUntil: + visible: + id: "TotalBalance-primary" + timeout: 5000 + +# recipient without contacts header, entered from the invoice editor +- openLink: bitkit://screen/receive/edit-invoice +- extendedWaitUntil: + visible: + id: "PaymentRequestSendButton" + timeout: 10000 +- tapOn: + id: "ReceiveNumberPadTextField" +- extendedWaitUntil: + visible: + id: "ReceiveNumberPad" + timeout: 5000 +- tapOn: + id: "N1" +- tapOn: + id: "ReceiveNumberPadSubmit" +- extendedWaitUntil: + visible: + id: "PaymentRequestSendButton" + timeout: 5000 +- tapOn: + id: "PaymentRequestSendButton" +- extendedWaitUntil: + visible: + id: "PaymentRequestContactsHeader" + timeout: 10000 +- assertVisible: "Alex Stronghand" +- assertVisible: "Anna Pleb" +- assertVisible: "Craig Wrong" +- assertVisible: "John Carvalho" +- takeScreenshot: + path: recipient-invoice-after diff --git a/.maestro/setup/onboard-new-wallet.yaml b/.maestro/setup/onboard-new-wallet.yaml new file mode 100644 index 0000000000..a138abe932 --- /dev/null +++ b/.maestro/setup/onboard-new-wallet.yaml @@ -0,0 +1,17 @@ +appId: to.bitkit.dev +name: onboard new wallet +--- +- extendedWaitUntil: + visible: + id: "Continue" + timeout: 30000 +- tapOn: + id: "Continue" +- tapOn: + id: "SkipIntro" +- tapOn: + id: "NewWallet" +- extendedWaitUntil: + visible: + id: "TotalBalance-primary" + timeout: 180000 diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt index 69456ee0c2..3d3f3e8cd3 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt @@ -49,7 +49,6 @@ class CreatePaymentRequestScreenTest { initialDraft = draft, contact = PubkyProfile.placeholder(target.publicKey), isCreating = false, - onBack = {}, onEditAmount = {}, onSend = {}, ) @@ -60,6 +59,7 @@ class CreatePaymentRequestScreenTest { composeTestRule.onNodeWithTag("PaymentRequestExpiryWeek").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestSend").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestNumberPad").assertDoesNotExist() + composeTestRule.onNodeWithText("AMOUNT").assertDoesNotExist() } @Test @@ -117,6 +117,7 @@ class CreatePaymentRequestScreenTest { composeTestRule.onNodeWithTag("PaymentRequestContact${target.publicKey}").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestRecipientSearch").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestRecipientPaste", useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestContactsHeader").assertDoesNotExist() composeTestRule.onNodeWithTag("PaymentRequestSend").assertDoesNotExist() composeTestRule.onNodeWithTag("PaymentRequestContact${target.publicKey}").performClick() assertEquals(target, selectedTarget) @@ -127,6 +128,43 @@ class CreatePaymentRequestScreenTest { composeTestRule.onNodeWithText("No matching saved contact with a private connection.").assertIsDisplayed() } + @Test + fun recipientFromInvoiceShowsContactsHeader() { + composeTestRule.setContent { + AppThemeSurface { + PaymentRequestRecipientContent( + targets = persistentListOf(target), + contacts = persistentListOf(PubkyProfile.placeholder(target.publicKey)), + onBack = {}, + onPaste = { target.publicKey }, + onSelected = {}, + showContactsHeader = true, + ) + } + } + + composeTestRule.onNodeWithTag("PaymentRequestContactsHeader").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestContact${target.publicKey}").assertIsDisplayed() + } + + @Test + fun detailsFromInvoiceShowsAmountLabelInsteadOfFiat() { + composeTestRule.setContent { + AppThemeSurface { + PaymentRequestDetailsContent( + initialDraft = draft, + contact = PubkyProfile.placeholder(target.publicKey), + isCreating = false, + onEditAmount = {}, + onSend = {}, + fromInvoiceEditor = true, + ) + } + } + + composeTestRule.onNodeWithText("AMOUNT").assertIsDisplayed() + } + @Test fun sentShowsSuccessSurface() { val contact = PubkyProfile.forDisplay(target.publicKey, "Anna", imageUrl = null) 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 91b52e94ce..24c3876c61 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 @@ -118,6 +118,34 @@ class PaymentRequestsScreenTest { composeTestRule.onNodeWithText("Weekly coffee").assertIsDisplayed() } + @Test + fun listShowsIncomingSectionWithPayAndDismiss() { + val incoming = request(id = "incoming") + + composeTestRule.setContent { + PaymentRequestsTestSurface { + PaymentRequestsContent( + requests = persistentListOf(incoming), + pending = persistentListOf(incoming), + contacts = persistentListOf(), + subscriptions = persistentListOf(), + dismissingRequestIds = persistentSetOf(), + canRequestPayment = true, + onBack = {}, + onRequestPayment = {}, + onPay = {}, + onDismiss = { Result.success(Unit) }, + onDetails = {}, + ) + } + } + + composeTestRule.onNodeWithText("INCOMING").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestRowincoming").assertIsDisplayed() + composeTestRule.onNodeWithText("Pay").assertIsDisplayed() + composeTestRule.onNodeWithText("Dismiss").assertIsDisplayed() + } + @Test fun historyGroupsCompletedRequestsAndKeepsActiveOutgoingRequests() { val now = Clock.System.now() @@ -151,8 +179,10 @@ class PaymentRequestsScreenTest { composeTestRule.onNodeWithTag("PaymentRequestRowaccepted").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestRowoutgoing").assertIsDisplayed() - composeTestRule.onNodeWithText("Waiting for", substring = true).assertIsDisplayed() - composeTestRule.onNodeWithText("PAYMENT REQUESTS").assertIsDisplayed() + composeTestRule.onNodeWithText("Dinner").assertIsDisplayed() + composeTestRule.onNodeWithText("INCOMING").assertDoesNotExist() + composeTestRule.onNodeWithText("OUTGOING").assertIsDisplayed() + composeTestRule.onNodeWithText("pending").assertIsDisplayed() composeTestRule.onNodeWithText("TODAY").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestCreate").assertIsDisplayed() } diff --git a/app/src/debug/java/to/bitkit/viewmodels/PaymentRequestFixtureRuntime.kt b/app/src/debug/java/to/bitkit/viewmodels/PaymentRequestFixtureRuntime.kt new file mode 100644 index 0000000000..3be4b4cd63 --- /dev/null +++ b/app/src/debug/java/to/bitkit/viewmodels/PaymentRequestFixtureRuntime.kt @@ -0,0 +1,95 @@ +package to.bitkit.viewmodels + +import android.net.Uri +import com.synonym.paykit.PaymentRequestLifecycleState +import org.lightningdevkit.ldknode.Network +import to.bitkit.env.Env +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus +import to.bitkit.repositories.PaykitPaymentRequestDirection +import to.bitkit.repositories.PaykitPaymentRequestTarget +import to.bitkit.ui.utils.ScreenDeepLinks +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +/** + * Figma frames on Bitkit - Handoff v62: list 48056:274346, recipient 48030:206543, + * recipient with contacts header 48049:273376, amount 48030:206614 and 48030:206606, + * details 47993:199959 and 48049:273665, sent 48030:206596 and 46920:240230. + */ +internal object PaymentRequestFixtureRuntime { + private const val HOST = "dev-fixture" + private const val PATH = "payment-request" + private const val CLEAR = "clear" + private const val RECEIVER_PATH = "bitkit/wallet" + private const val BOLT11 = "btc-lightning-bolt11" + + private val alex = profile("pubkyyrsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", "Alex Stronghand") + private val anna = profile("pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", "Anna Pleb") + private val areem = profile("pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", "Areem Holden") + private val craig = profile("pubkybrsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", "Craig Wrong") + private val john = profile("pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", "John Carvalho") + private val paola = profile("pubkynrsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", "Paola Andina") + private val ben = profile("pubky8rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", "Ben") + + fun linkFor(uri: Uri): PaymentRequestFixtureLink? { + if (Env.network != Network.REGTEST) return null + if (uri.scheme?.lowercase() != ScreenDeepLinks.SCHEME) return null + if (uri.host?.lowercase() != HOST) return null + val segments = uri.pathSegments.map { it.lowercase() } + if (segments.firstOrNull() != PATH) return null + val isClear = segments.getOrNull(1) == CLEAR || uri.queryParameterNames.any { it.lowercase() == CLEAR } + return when { + isClear -> PaymentRequestFixtureLink.Clear + segments.size == 1 -> PaymentRequestFixtureLink.Seed(fixture()) + else -> null + } + } + + private fun fixture(): PaymentRequestFixture { + val now = Clock.System.now() + return PaymentRequestFixture( + contacts = listOf(alex, anna, areem, ben, craig, john, paola), + targets = listOf(alex, anna, areem, craig, john, paola).map(::target), + pending = listOf( + request("incoming-areem", areem, 21_000uL, "Lunch last week", now), + request("incoming-ben", ben, 100_000uL, "Groceries", now), + ), + history = listOf( + request("outgoing-anna", anna, 14_500uL, "Snacks at conference", now, isOutgoing = true), + request("outgoing-john", john, 50_000uL, "Steaks & Burgers", now, isOutgoing = true), + ), + ) + } + + private fun target(profile: PubkyProfile) = PaykitPaymentRequestTarget( + publicKey = profile.publicKey, + receiverPath = RECEIVER_PATH, + ) + + private fun request( + id: String, + counterparty: PubkyProfile, + amountSats: ULong, + note: String, + createdAt: Instant, + isOutgoing: Boolean = false, + ) = PaykitPaymentRequest( + paymentRequestId = id, + counterparty = counterparty.publicKey, + counterpartyReceiverPath = RECEIVER_PATH, + amountValue = amountSats.toString(), + amountSats = amountSats, + note = note, + createdAt = createdAt, + expiresAt = createdAt + 7.days, + acceptedPaymentEndpointIdentifiers = listOf(BOLT11), + deliveryStatus = PaykitPaymentRequestDeliveryStatus.Sent.takeIf { isOutgoing }, + direction = if (isOutgoing) PaykitPaymentRequestDirection.Outgoing else PaykitPaymentRequestDirection.Incoming, + lifecycleState = PaymentRequestLifecycleState.PROPOSED, + ) + + private fun profile(publicKey: String, name: String) = PubkyProfile.forDisplay(publicKey, name, null) +} 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 00443e067c..4fa25e9baf 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -47,6 +47,7 @@ fun MoneyCell( sats: Long, modifier: Modifier = Modifier, prefix: String = "", + secondaryText: String? = null, ) { val currencies = LocalCurrencies.current Column( @@ -60,12 +61,20 @@ fun MoneyCell( modifier = Modifier.testTag("MoneyPrimary"), ) } - rememberMoneyText(sats = sats, unit = currencies.primaryDisplay.not(), showSymbol = true)?.let { text -> + if (secondaryText != null) { CaptionB( - text = text.withAccent(accentColor = Colors.White64), + text = secondaryText, color = Colors.White64, - modifier = Modifier.testTag("MoneySecondary"), + modifier = Modifier.testTag("MoneySecondary") ) + } else { + rememberMoneyText(sats = sats, unit = currencies.primaryDisplay.not(), showSymbol = true)?.let { text -> + CaptionB( + text = text.withAccent(accentColor = Colors.White64), + color = Colors.White64, + modifier = Modifier.testTag("MoneySecondary") + ) + } } } } 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 f2e837add0..1ae15563a2 100644 --- a/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt @@ -32,7 +32,7 @@ fun PubkyContactRow( modifier: Modifier = Modifier, isSelected: Boolean? = null, isEnabled: Boolean = true, - verticalPadding: Dp = 12.dp, + verticalPadding: Dp = 24.dp, selectionColor: Color = Colors.PubkyGreen, ) { Row( @@ -59,13 +59,13 @@ fun PubkyContactRow( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.weight(1f) ) { - BodyS( + Text13Up( text = profile.truncatedPublicKey, color = Colors.White64, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - BodySSB( + BodyMSB( text = profile.name, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt index e28a15d763..87c522c562 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt @@ -8,9 +8,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -85,6 +87,18 @@ import kotlin.time.Duration.Companion.hours import kotlin.time.ExperimentalTime import kotlin.time.Instant +/** Design keypad height: the 347dp Keyboard/NumPad instance of the v62 amount frames, four rows of 88dp. */ +private val NUMBER_PAD_HEIGHT = 352.dp + +/** Space the amount step needs besides the keypad: amount field, toggle, divider, button, spacers. */ +private val AMOUNT_STEP_FIXED_HEIGHT = 264.dp + +/** Space the sent step needs below the illustration: headline, subtitle, card, OK button, spacers. */ +private val SENT_FIXED_CONTENT_HEIGHT = 376.dp + +/** Checkmark illustration height as a fraction of the sent screen body. */ +private const val SENT_CHECK_HEIGHT_FRACTION = 0.43f + enum class PaymentRequestExpiration(val duration: Duration) { Hour(1.hours), Day(1.days), @@ -151,22 +165,25 @@ internal fun PaymentRequestAmountContent( { PubkyContactAvatar( profile = it, - size = 32.dp, - modifier = Modifier.padding(end = 8.dp), + size = 24.dp, + modifier = Modifier.padding(end = 8.dp) ) } }, ) BoxWithConstraints(modifier = Modifier.weight(1f)) { - val availableHeight = this.maxHeight - - Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + val keypadHeight = (maxHeight - AMOUNT_STEP_FIXED_HEIGHT).coerceIn(0.dp, NUMBER_PAD_HEIGHT) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { VerticalSpacer(16.dp) NumberPadTextField( viewModel = amountInputViewModel, modifier = Modifier .fillMaxWidth() - .testTag("PaymentRequestAmountField"), + .testTag("PaymentRequestAmountField") ) FillHeight(min = 12.dp) Row(modifier = Modifier.fillMaxWidth()) { @@ -174,26 +191,29 @@ internal fun PaymentRequestAmountContent( UnitButton( onClick = { amountInputViewModel.switchUnit(currencies) }, color = Colors.Brand, - modifier = Modifier.testTag("PaymentRequestAmountUnit"), + modifier = Modifier.testTag("PaymentRequestAmountUnit") ) } - VerticalSpacer(16.dp) + VerticalSpacer(12.dp) HorizontalDivider(color = Colors.White10) + VerticalSpacer(16.dp) NumberPad( viewModel = amountInputViewModel, currencies = currencies, - availableHeight = availableHeight, - modifier = Modifier.testTag("PaymentRequestNumberPad"), + modifier = Modifier + .height(keypadHeight) + .testTag("PaymentRequestNumberPad") ) + FillHeight(min = 12.dp) PrimaryButton( text = stringResource(R.string.common__continue), enabled = amountState.sats > 0, onClick = { onContinue(initialDraft.copy(amountSats = amountState.sats.toULong())) }, - modifier = Modifier.testTag("PaymentRequestAmountContinue"), + modifier = Modifier.testTag("PaymentRequestAmountContinue") ) - VerticalSpacer(16.dp) + VerticalSpacer(22.dp) } } } @@ -204,9 +224,9 @@ fun PaymentRequestDetailsScreen( appViewModel: AppViewModel, draft: PaykitPaymentRequestDraft, target: PaykitPaymentRequestTarget, - onBack: () -> Unit, onEditAmount: (PaykitPaymentRequestDraft) -> Unit, onSent: (PaykitPaymentRequest) -> Unit, + fromInvoiceEditor: Boolean = false, ) { val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() val isCreating by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() @@ -217,9 +237,9 @@ fun PaymentRequestDetailsScreen( initialDraft = draft, contact = contact, isCreating = isCreating, - onBack = onBack, onEditAmount = onEditAmount, onSend = { updatedDraft -> appViewModel.createPaymentRequest(updatedDraft, target, onSent) }, + fromInvoiceEditor = fromInvoiceEditor, ) } @@ -228,10 +248,10 @@ internal fun PaymentRequestDetailsContent( initialDraft: PaykitPaymentRequestDraft, contact: PubkyProfile, isCreating: Boolean, - onBack: () -> Unit, onEditAmount: (PaykitPaymentRequestDraft) -> Unit, onSend: (PaykitPaymentRequestDraft) -> Unit, modifier: Modifier = Modifier, + fromInvoiceEditor: Boolean = false, ) { var note by remember(initialDraft.note) { mutableStateOf(initialDraft.note) } var expiration by remember(initialDraft.expiresAt) { @@ -250,17 +270,19 @@ internal fun PaymentRequestDetailsContent( .padding(horizontal = 16.dp) .testTag("PaymentRequestDetails") ) { - SheetTopBar( - titleText = stringResource(R.string.wallet__payment_request), - onBack = onBack, - ) - rememberMoneyText( + SheetTopBar(titleText = stringResource(R.string.wallet__payment_request)) + VerticalSpacer(16.dp) + val fiatCaption = rememberMoneyText( sats = initialDraft.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), reversed = true, showSymbol = true, - )?.let { - Caption13Up(text = it.removeAccentTags(), color = Colors.White64) - } + )?.removeAccentTags() + Caption13Up( + text = fiatCaption.takeUnless { fromInvoiceEditor } + ?: stringResource(R.string.wallet__payment_request_amount), + color = Colors.White64, + ) + VerticalSpacer(16.dp) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -285,7 +307,7 @@ internal fun PaymentRequestDetailsContent( ) } } - VerticalSpacer(20.dp) + VerticalSpacer(24.dp) Caption13Up(text = stringResource(R.string.wallet__payment_request_note), color = Colors.White64) VerticalSpacer(8.dp) TextInput( @@ -297,15 +319,15 @@ internal fun PaymentRequestDetailsContent( .fillMaxWidth() .testTag("PaymentRequestNote"), ) - VerticalSpacer(20.dp) + VerticalSpacer(30.dp) Caption13Up(text = stringResource(R.string.wallet__payment_request_recipient), color = Colors.White64) - VerticalSpacer(8.dp) + VerticalSpacer(12.dp) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .background(Colors.Gray6, RoundedCornerShape(16.dp)) - .padding(16.dp), + .padding(20.dp), ) { PubkyContactAvatar(profile = contact, size = 40.dp) Column(modifier = Modifier.padding(start = 16.dp).weight(1f)) { @@ -318,9 +340,9 @@ internal fun PaymentRequestDetailsContent( } MoneyCell(sats = initialDraft.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong()) } - VerticalSpacer(20.dp) + VerticalSpacer(30.dp) Caption13Up(text = stringResource(R.string.wallet__payment_request_expires), color = Colors.White64) - VerticalSpacer(8.dp) + VerticalSpacer(20.dp) Row(modifier = Modifier.fillMaxWidth()) { PaymentRequestExpiration.entries.forEach { option -> val isSelected = option == expiration @@ -332,7 +354,7 @@ internal fun PaymentRequestDetailsContent( .testTag("PaymentRequestExpiry${option.name}"), ) { BodyS(text = option.title(), color = if (isSelected) Colors.White else Colors.White64) - VerticalSpacer(8.dp) + VerticalSpacer(12.dp) HorizontalDivider( thickness = 2.dp, color = if (isSelected) Colors.White else Colors.White16, @@ -355,7 +377,7 @@ internal fun PaymentRequestDetailsContent( }, modifier = Modifier.testTag("PaymentRequestSend"), ) - VerticalSpacer(16.dp) + VerticalSpacer(22.dp) } } @@ -364,6 +386,7 @@ fun PaymentRequestRecipientScreen( appViewModel: AppViewModel, onBack: () -> Unit, onSelected: (PaykitPaymentRequestTarget) -> Unit, + showContactsHeader: Boolean = false, ) { val context = LocalContext.current val targets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() @@ -375,6 +398,7 @@ fun PaymentRequestRecipientScreen( onBack = onBack, onPaste = { context.getClipboardText()?.trim().orEmpty() }, onSelected = onSelected, + showContactsHeader = showContactsHeader, ) } @@ -386,6 +410,7 @@ internal fun PaymentRequestRecipientContent( onBack: () -> Unit, onPaste: () -> String, onSelected: (PaykitPaymentRequestTarget) -> Unit, + showContactsHeader: Boolean = false, ) { var query by remember { mutableStateOf("") } @@ -412,6 +437,7 @@ internal fun PaymentRequestRecipientContent( titleText = stringResource(R.string.wallet__payment_request_choose_recipient), onBack = onBack, ) + VerticalSpacer(16.dp) Caption13Up(text = stringResource(R.string.wallet__payment_request_recipient), color = Colors.White64) VerticalSpacer(8.dp) TextInput( @@ -443,11 +469,21 @@ internal fun PaymentRequestRecipientContent( .fillMaxWidth() .testTag("PaymentRequestRecipientSearch"), ) - VerticalSpacer(24.dp) - Caption13Up(text = stringResource(R.string.contacts__contacts_header), color = Colors.White64) - VerticalSpacer(8.dp) + VerticalSpacer(16.dp) + if (showContactsHeader) { + Caption13Up( + text = stringResource(R.string.contacts__contacts_header), + color = Colors.White64, + modifier = Modifier + .padding(vertical = 20.dp) + .testTag("PaymentRequestContactsHeader") + ) + } HorizontalDivider(color = Colors.White10) - LazyColumn(modifier = Modifier.weight(1f)) { + LazyColumn( + contentPadding = PaddingValues(top = 6.dp), + modifier = Modifier.weight(1f) + ) { if (recipients.isEmpty()) { item { BodyM( @@ -474,7 +510,7 @@ internal fun PaymentRequestRecipientContent( PubkyContactRow( profile = contact, onClick = { onSelected(target) }, - verticalPadding = 16.dp, + verticalPadding = 28.dp, modifier = Modifier.testTag("PaymentRequestContact${contact.publicKey}"), ) HorizontalDivider(color = Colors.White10) @@ -511,51 +547,57 @@ internal fun PaymentRequestSentContent( .testTag("PaymentRequestSent"), ) { SheetTopBar(titleText = stringResource(R.string.wallet__payment_request_sent_title)) - VerticalSpacer(32.dp) - Image( - painter = painterResource(R.drawable.check), - contentDescription = null, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .size(256.dp) - .testTag("PaymentRequestSentCheck"), - ) - VerticalSpacer(32.dp) - Display( - text = stringResource(R.string.wallet__payment_request_sent_headline) - .withAccent(accentColor = Colors.Purple), - ) - VerticalSpacer(12.dp) - BodyM( - text = stringResource( - if (request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { - R.string.wallet__payment_request_sent_description - } else { - R.string.wallet__payment_request_queued_description - } - ), - color = Colors.White64, - textAlign = TextAlign.Start, - modifier = Modifier.fillMaxWidth(), - ) - VerticalSpacer(24.dp) - PaymentRequestCard( - request = request, - contact = contact, - compactSubtitle = request.note?.takeIf(String::isNotBlank) ?: if ( - request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent - ) { - stringResource(R.string.wallet__payment_request_waiting) - } else { - stringResource(R.string.wallet__payment_request_sending) - }, - ) - VerticalSpacer(32.dp) - PrimaryButton( - text = stringResource(R.string.common__ok), - onClick = onDone, - ) - VerticalSpacer(16.dp) + BoxWithConstraints(modifier = Modifier.weight(1f)) { + val checkSize = minOf(maxHeight * SENT_CHECK_HEIGHT_FRACTION, maxHeight - SENT_FIXED_CONTENT_HEIGHT) + .coerceAtLeast(0.dp) + Column(modifier = Modifier.fillMaxSize()) { + FillHeight() + Image( + painter = painterResource(R.drawable.check), + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .size(checkSize) + .testTag("PaymentRequestSentCheck") + ) + VerticalSpacer(32.dp) + Display( + text = stringResource(R.string.wallet__payment_request_sent_headline) + .withAccent(accentColor = Colors.Purple), + ) + VerticalSpacer(8.dp) + BodyM( + text = stringResource( + if (request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { + R.string.wallet__payment_request_sent_description + } else { + R.string.wallet__payment_request_queued_description + } + ), + color = Colors.White64, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + VerticalSpacer(24.dp) + PaymentRequestCard( + request = request, + contact = contact, + compactSubtitle = request.note?.takeIf(String::isNotBlank) ?: if ( + request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent + ) { + stringResource(R.string.wallet__payment_request_waiting) + } else { + stringResource(R.string.wallet__payment_request_sending) + }, + ) + VerticalSpacer(44.dp) + PrimaryButton( + text = stringResource(R.string.common__ok), + onClick = onDone, + ) + VerticalSpacer(16.dp) + } + } } } @@ -601,7 +643,6 @@ private fun PaymentRequestDetailsPreview() { initialDraft = previewDraft, contact = PubkyProfile.placeholder(previewTarget.publicKey), isCreating = false, - onBack = {}, onEditAmount = {}, onSend = {}, modifier = Modifier.sheetHeight(), 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 4bdce600df..49184ffa54 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 @@ -278,29 +278,48 @@ internal fun PaymentRequestsContent( } } else { LazyColumn( - contentPadding = PaddingValues(top = 24.dp, bottom = 16.dp), + contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .weight(1f) .padding(horizontal = 16.dp), ) { - if (sections.active.isNotEmpty()) { + if (sections.incoming.isNotEmpty()) { item { Caption13Up( - text = stringResource(R.string.wallet__payment_requests_section), + text = stringResource(R.string.wallet__payment_requests_incoming), color = Colors.White64, ) } - items(sections.active, key = { it.lazyListKey }) { request -> - ActivePaymentRequestCard( + items(sections.incoming, key = { it.lazyListKey }) { request -> + PaymentRequestCard( request = request, - isIncoming = pending.any { it.id == request.id }, + contact = contacts.contactFor(request), + compactSubtitle = subscriptions.nameFor(request), + onClick = { onDetails(request.id) }, isDismissing = request.id in dismissingRequestIds, + onPay = { onPay(request.id) }, + onDismiss = { onDismiss(request) }, + ) + } + } + if (sections.outgoing.isNotEmpty()) { + item { + Caption13Up( + text = stringResource(R.string.wallet__payment_requests_outgoing), + color = Colors.White64, + modifier = Modifier.padding(top = 16.dp) + ) + } + items(sections.outgoing, key = { it.lazyListKey }) { request -> + PaymentRequestCard( + request = request, contact = contacts.contactFor(request), - subscriptionNote = subscriptions.nameFor(request), - onPay = onPay, - onDismiss = onDismiss, - onDetails = onDetails, + compactSubtitle = subscriptions.nameFor(request) + ?: request.note?.takeIf(String::isNotBlank) + ?: paymentRequestStatus(request), + secondaryText = stringResource(R.string.wallet__payment_request_pending), + onClick = { onDetails(request.id) }, ) } } @@ -340,7 +359,8 @@ internal fun PaymentRequestsContent( } private data class PaymentRequestSections( - val active: List, + val incoming: List, + val outgoing: List, val history: List, ) @@ -363,13 +383,14 @@ private fun paymentRequestSections( now: Instant, ): PaymentRequestSections { val pendingIds = pending.mapTo(mutableSetOf()) { it.id } - val active = requests.filter { request -> - request.id in pendingIds || - request.direction == PaykitPaymentRequestDirection.Outgoing && + val incoming = requests.filter { it.id in pendingIds } + val outgoing = requests.filter { request -> + request.direction == PaykitPaymentRequestDirection.Outgoing && request.lifecycleState == PaymentRequestLifecycleState.PROPOSED && - !request.isExpired(now) + !request.isExpired(now) && + request.id !in pendingIds } - val activeIds = active.mapTo(mutableSetOf()) { it.id } + val activeIds = (incoming + outgoing).mapTo(mutableSetOf()) { it.id } val groupedHistory = requests .filterNot { it.id in activeIds } .sortedWith { first, second -> compareValues(second.createdAt, first.createdAt) } @@ -377,41 +398,7 @@ private fun paymentRequestSections( val history = PaymentRequestHistoryPeriod.entries.mapNotNull { period -> groupedHistory[period]?.let { PaymentRequestHistorySection(period, it) } } - return PaymentRequestSections(active, history) -} - -@Composable -private fun ActivePaymentRequestCard( - request: PaykitPaymentRequest, - isIncoming: Boolean, - isDismissing: Boolean, - contact: PubkyProfile?, - subscriptionNote: String?, - onPay: (PaykitPaymentRequestId) -> Unit, - onDismiss: suspend (PaykitPaymentRequest) -> Result, - onDetails: (PaykitPaymentRequestId) -> Unit, -) { - if (isIncoming) { - PaymentRequestCard( - request = request, - contact = contact, - compactSubtitle = subscriptionNote, - onClick = { onDetails(request.id) }, - isDismissing = isDismissing, - onPay = { onPay(request.id) }, - onDismiss = { onDismiss(request) }, - ) - } else { - PaymentRequestCard( - request = request, - contact = contact, - onClick = { onDetails(request.id) }, - compactSubtitle = stringResource( - R.string.wallet__payment_request_waiting_for_recipient, - contact?.name ?: PubkyProfile.placeholder(request.counterparty).name, - ), - ) - } + return PaymentRequestSections(incoming, outgoing, history) } @Composable @@ -488,6 +475,7 @@ internal fun PaymentRequestCard( request: PaykitPaymentRequest, contact: PubkyProfile?, compactSubtitle: String? = null, + secondaryText: String? = null, isOutgoingPayment: Boolean = false, showSignedAmount: Boolean = false, onClick: (() -> Unit)? = null, @@ -513,7 +501,7 @@ internal fun PaymentRequestCard( .outerGlow( glowColor = Colors.Brand, glowOpacity = 0.16f, - glowRadius = 64.dp, + glowRadius = 16.dp, cornerRadius = 16.dp, ) .border(1.dp, Colors.Brand.copy(alpha = 0.5f), MaterialTheme.shapes.medium) @@ -550,6 +538,7 @@ internal fun PaymentRequestCard( MoneyCell( sats = request.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), prefix = amountPrefix, + secondaryText = secondaryText, ) } if (onPay != null || onDismiss != null) { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index d8beebbd55..3909fee354 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -222,6 +222,7 @@ fun ReceiveSheet( } ) }, + showContactsHeader = skipPaymentRequestAmount, ) } composableWithDefaultTransitions { @@ -231,7 +232,7 @@ fun ReceiveSheet( appViewModel = appViewModel, draft = paymentRequestDraft, target = target, - onBack = { navController.popBackStack() }, + fromInvoiceEditor = skipPaymentRequestAmount, onEditAmount = { paymentRequestDraft = it isEditingPaymentRequestAmount = true diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b80600e2b9..76a5f83c16 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -328,9 +328,10 @@ class AppViewModel @Inject constructor( private val _currentSheet: MutableStateFlow = MutableStateFlow(null) val currentSheet = _currentSheet.asStateFlow() - val pendingPaymentRequests = paykitPaymentRequestRepo.pendingRequests - val paymentRequestHistory = paykitPaymentRequestRepo.paymentRequestHistory - val eligiblePaymentRequestTargets = paykitPaymentRequestRepo.eligibleTargets + private val paymentRequestFixture = MutableStateFlow(null) + val pendingPaymentRequests = fixtureOr(paykitPaymentRequestRepo.pendingRequests) { it.pending } + val paymentRequestHistory = fixtureOr(paykitPaymentRequestRepo.paymentRequestHistory) { it.history } + val eligiblePaymentRequestTargets = fixtureOr(paykitPaymentRequestRepo.eligibleTargets) { it.targets } val isCreatingPaymentRequest = paykitPaymentRequestRepo.isCreatingRequest private val _rejectingPaymentRequestIds = MutableStateFlow>(emptySet()) val rejectingPaymentRequestIds = _rejectingPaymentRequestIds.asStateFlow() @@ -339,7 +340,7 @@ class AppViewModel @Inject constructor( private val _isRetryingInitialSubscriptionPayment = MutableStateFlow(false) val isRetryingInitialSubscriptionPayment = _isRetryingInitialSubscriptionPayment.asStateFlow() val subscriptions = paykitPaymentRequestRepo.subscriptions - val pubkyContacts = pubkyRepo.contacts + val pubkyContacts = fixtureOr(pubkyRepo.contacts) { it.contacts } private var sheetTransitionJob: Job? = null private var paymentRequestSheetTransitionJob: Job? = null private var queuedPairingCodeRequestId: Long? = null @@ -385,6 +386,10 @@ class AppViewModel @Inject constructor( .map { PaykitFeatureFlags.isUiEnabled(it) } .stateIn(viewModelScope, SharingStarted.Eagerly, false) + private fun fixtureOr(source: StateFlow, select: (PaymentRequestFixture) -> T): StateFlow = + combine(paymentRequestFixture, source) { fixture, value -> fixture?.let(select) ?: value } + .stateIn(viewModelScope, SharingStarted.Eagerly, source.value) + fun setShowForgotPin(value: Boolean) { _showForgotPinSheet.value = value } @@ -4962,6 +4967,10 @@ class AppViewModel @Inject constructor( target: PaykitPaymentRequestTarget, onCreated: (PaykitPaymentRequest) -> Unit, ) { + paymentRequestFixture.value?.let { + onCreated(it.created(draft, target)) + return + } val sourceReceiveSheet = currentSheet.value as? Sheet.Receive viewModelScope.launch { createPaymentRequest(draft, target) @@ -5084,6 +5093,21 @@ class AppViewModel @Inject constructor( } private fun processDeeplink(uri: Uri) = viewModelScope.launch { + when (val link = PaymentRequestFixtureRuntime.linkFor(uri)) { + is PaymentRequestFixtureLink.Seed -> { + settingsStore.setIsPaykitEnabled(true) + paymentRequestFixture.update { link.fixture } + return@launch + } + + PaymentRequestFixtureLink.Clear -> { + paymentRequestFixture.update { null } + return@launch + } + + null -> Unit + } + val value = uri.toString() if (SamRockSetupRequest.isProtocolUrl(value)) { if (!walletRepo.walletExists()) return@launch diff --git a/app/src/main/java/to/bitkit/viewmodels/PaymentRequestFixture.kt b/app/src/main/java/to/bitkit/viewmodels/PaymentRequestFixture.kt new file mode 100644 index 0000000000..a9df09b549 --- /dev/null +++ b/app/src/main/java/to/bitkit/viewmodels/PaymentRequestFixture.kt @@ -0,0 +1,40 @@ +package to.bitkit.viewmodels + +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus +import to.bitkit.repositories.PaykitPaymentRequestDirection +import to.bitkit.repositories.PaykitPaymentRequestDraft +import to.bitkit.repositories.PaykitPaymentRequestTarget +import kotlin.time.Clock + +/** Seeded payment request data for debug UI captures, installed via `bitkit://dev-fixture/payment-request`. */ +data class PaymentRequestFixture( + val contacts: List, + val targets: List, + val pending: List, + val history: List, +) { + fun created(draft: PaykitPaymentRequestDraft, target: PaykitPaymentRequestTarget): PaykitPaymentRequest { + val now = Clock.System.now() + return PaykitPaymentRequest( + paymentRequestId = "fixture-${now.toEpochMilliseconds()}", + counterparty = target.publicKey, + counterpartyReceiverPath = target.receiverPath, + amountValue = draft.amountSats.toString(), + amountSats = draft.amountSats, + note = draft.note.ifBlank { null }, + createdAt = now, + expiresAt = draft.expiresAt, + acceptedPaymentEndpointIdentifiers = pending.firstOrNull()?.acceptedPaymentEndpointIdentifiers.orEmpty(), + deliveryStatus = PaykitPaymentRequestDeliveryStatus.Sent, + direction = PaykitPaymentRequestDirection.Outgoing, + ) + } +} + +/** What a `bitkit://dev-fixture/payment-request` link asks for: seed the fixture or clear it. */ +sealed interface PaymentRequestFixtureLink { + data class Seed(val fixture: PaymentRequestFixture) : PaymentRequestFixtureLink + data object Clear : PaymentRequestFixtureLink +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd5d2e4ff5..a19b1f1a14 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1260,6 +1260,7 @@ Or Pay ₿]]> Paste Pay + pending Your payment request is queued and will send automatically RECIPIENT No matching saved contact with a private connection. @@ -1282,18 +1283,18 @@ Time Waiting for payment Waiting for updated private payment details. Bitkit will retry automatically. - Waiting for %1$s to pay Payment Requests Earlier You have not made any payments to providers and don’t have any payment requests yet. History]]> + Incoming Not Now + Outgoing %1$d pending payment request %1$d pending payment requests Review each request, then pay or dismiss. - PAYMENT REQUESTS See All This Month This Week diff --git a/app/src/release/java/to/bitkit/viewmodels/PaymentRequestFixtureRuntime.kt b/app/src/release/java/to/bitkit/viewmodels/PaymentRequestFixtureRuntime.kt new file mode 100644 index 0000000000..d2f2d85133 --- /dev/null +++ b/app/src/release/java/to/bitkit/viewmodels/PaymentRequestFixtureRuntime.kt @@ -0,0 +1,8 @@ +package to.bitkit.viewmodels + +import android.net.Uri + +internal object PaymentRequestFixtureRuntime { + @Suppress("UNUSED_PARAMETER") + fun linkFor(uri: Uri): PaymentRequestFixtureLink? = null +} diff --git a/changelog.d/next/1196.fixed.md b/changelog.d/next/1196.fixed.md new file mode 100644 index 0000000000..a6cf160a52 --- /dev/null +++ b/changelog.d/next/1196.fixed.md @@ -0,0 +1 @@ +Payment request screens now follow the same spacing, contact rows, amount keypad, and success layout as the rest of Bitkit. \ No newline at end of file