diff --git a/.maestro/payment-request/payment-request.yaml b/.maestro/payment-request/payment-request.yaml new file mode 100644 index 0000000000..44528bfb65 --- /dev/null +++ b/.maestro/payment-request/payment-request.yaml @@ -0,0 +1,207 @@ +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 +- tapOn: + id: "HeaderMenu" +- tapOn: + id: "DrawerPaymentRequests" +- 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: "PaymentRequestEditAmount" + timeout: 10000 +- tapOn: + id: "PaymentRequestEditAmount" +- extendedWaitUntil: + visible: + id: "PaymentRequestNumberPad" + timeout: 5000 +- tapOn: + id: "N1" +- tapOn: + id: "N4" +- tapOn: + id: "N5" +- tapOn: + id: "N0" +- tapOn: + id: "N0" +- tapOn: + id: "PaymentRequestAmountDone" +- tapOn: + id: "PaymentRequestAmountContinue" +- extendedWaitUntil: + visible: + id: "PaymentRequestContactsHeader" + timeout: 10000 +- assertVisible: "Alex Stronghand" +- assertVisible: "Anna Pleb" +- assertVisible: "Areem Holden" +- assertVisible: "Craig Wrong" +- assertVisible: "John Carvalho" +- takeScreenshot: + path: recipient-after +- back +- extendedWaitUntil: + notVisible: + id: "PaymentRequestRecipient" + timeout: 5000 +- back +- extendedWaitUntil: + notVisible: + id: "ReceiveScreen" + timeout: 5000 +- runFlow: + when: + visible: + id: "PaymentRequestsScreen" + 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: "PaymentRequestRecipient" + timeout: 10000 +- assertNotVisible: + id: "PaymentRequestContactsHeader" +- assertVisible: "Alex Stronghand" +- assertVisible: "Anna Pleb" +- assertVisible: "Craig Wrong" +- assertVisible: "John Carvalho" +- takeScreenshot: + path: recipient-invoice-after + +# amount +- tapOn: + id: "PaymentRequestContactpubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" +- extendedWaitUntil: + visible: + id: "PaymentRequestEditAmount" + timeout: 10000 +- tapOn: + id: "PaymentRequestEditAmount" +- extendedWaitUntil: + visible: + id: "PaymentRequestNumberPad" + timeout: 5000 +- tapOn: + id: "NRemove" +- assertVisible: + id: "PaymentRequestNumberPadUnit" +- 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: "PaymentRequestAmountDone" + +# 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 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 aa46daadeb..00be994c89 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 @@ -7,8 +7,8 @@ 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.performClick +import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput -import kotlinx.collections.immutable.persistentListOf import org.junit.Rule import org.junit.Test import to.bitkit.models.PubkyProfile @@ -53,33 +53,82 @@ class CreatePaymentRequestScreenTest { composeTestRule.onNodeWithTag("PaymentRequestNote").assertDoesNotExist() } + @Test + fun detailsWithRecipientShowsCardAndSendRequest() { + composeTestRule.setContent { + AppThemeSurface { + PaymentRequestDetailsContent( + amountInputViewModel = AmountInputViewModel(AmountInputHandler.stub()), + initialDraft = draft, + onBack = {}, + onContinue = {}, + recipient = PubkyProfile.placeholder(target.publicKey), + ) + } + } + + composeTestRule.onNodeWithTag("PaymentRequestSend").assertIsDisplayed() + composeTestRule.onNodeWithText("Send Request").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestAmountContinue").assertDoesNotExist() + } + @Test fun recipientShowsEligibleContactAndSendAction() { + val contacts = PaymentRequestFigmaFixtures.recipientContacts composeTestRule.setContent { AppThemeSurface { PaymentRequestRecipientContent( - targets = persistentListOf(target), - contacts = persistentListOf(PubkyProfile.placeholder(target.publicKey)), + targets = PaymentRequestFigmaFixtures.recipientTargets, + contacts = contacts, isCreating = false, - onEditExpiration = {}, - onPaste = { target.publicKey }, + onBack = {}, + onPaste = { PaymentRequestFigmaFixtures.anna.publicKey }, onSend = {}, ) } } - composeTestRule.onNodeWithTag("PaymentRequestContact${target.publicKey}").assertIsDisplayed() + composeTestRule.onNodeWithText("Alex Stronghand").assertIsDisplayed() + composeTestRule.onNodeWithText("Anna Pleb").assertIsDisplayed() + composeTestRule.onNodeWithText("Areem Holden").assertIsDisplayed() + composeTestRule.onNodeWithText("Craig Wrong").assertIsDisplayed() + composeTestRule.onNodeWithText("John Carvalho").performScrollTo().assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestRecipientSearch").assertIsDisplayed() - composeTestRule.onNodeWithTag("PaymentRequestEditExpiration").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestContactsHeader").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestRecipientPaste", useUnmergedTree = true).assertIsDisplayed() - composeTestRule.onNodeWithTag("PaymentRequestSend").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestSend").assertDoesNotExist() composeTestRule.onNodeWithTag("PaymentRequestRecipientSearch").performTextInput("not this contact") - composeTestRule.onNodeWithTag("PaymentRequestContact${target.publicKey}").assertDoesNotExist() + composeTestRule.onNodeWithText("Alex Stronghand").assertDoesNotExist() composeTestRule.onNodeWithText("No matching saved contact with a private connection.").assertIsDisplayed() } + @Test + fun recipientFromInvoiceHidesContactsHeader() { + composeTestRule.setContent { + AppThemeSurface { + PaymentRequestRecipientContent( + targets = PaymentRequestFigmaFixtures.recipientInvoiceTargets, + contacts = PaymentRequestFigmaFixtures.recipientInvoiceContacts, + isCreating = false, + onBack = {}, + onPaste = { PaymentRequestFigmaFixtures.anna.publicKey }, + onSend = {}, + showContactsHeader = false, + ) + } + } + + composeTestRule.onNodeWithTag("PaymentRequestContactsHeader").assertDoesNotExist() + composeTestRule.onNodeWithText("Alex Stronghand").assertIsDisplayed() + composeTestRule.onNodeWithText("Anna Pleb").assertIsDisplayed() + composeTestRule.onNodeWithText("Areem Holden").assertIsDisplayed() + composeTestRule.onNodeWithText("Craig Wrong").assertIsDisplayed() + composeTestRule.onNodeWithText("John Carvalho").performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithText("Paola Andina").performScrollTo().assertIsDisplayed() + } + @Test fun sentShowsSuccessSurface() { composeTestRule.setContent { @@ -95,7 +144,7 @@ class CreatePaymentRequestScreenTest { composeTestRule.onNodeWithTag("PaymentRequestSent").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestSentCheck").assertIsDisplayed() composeTestRule.onNodeWithText("PAYMENT REQUESTED").assertIsDisplayed() - composeTestRule.onNodeWithText("Waiting for payment").assertIsDisplayed() + composeTestRule.onNodeWithText("Dinner").assertIsDisplayed() } private val draft = PaykitPaymentRequestDraft( diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestFigmaFixtures.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestFigmaFixtures.kt new file mode 100644 index 0000000000..94c275f743 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestFigmaFixtures.kt @@ -0,0 +1,100 @@ +package to.bitkit.ui.screens.paymentrequests + +import com.synonym.paykit.PaymentRequestLifecycleState +import kotlinx.collections.immutable.persistentListOf +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 kotlin.time.Instant + +/** Figma frames 48185:303457, 48185:303440, 48185:303376. */ +internal object PaymentRequestFigmaFixtures { + val alex = profile("pubkyyrsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", "Alex Stronghand") + val anna = profile("pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", "Anna Pleb") + val areem = profile("pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", "Areem Holden") + val craig = profile("pubkybrsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", "Craig Wrong") + val john = profile("pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", "John Carvalho") + val paola = profile("pubkynrsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", "Paola Andina") + val ben = profile("pubky8rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", "Ben") + + val recipientContacts = persistentListOf(alex, anna, areem, craig, john) + val recipientInvoiceContacts = persistentListOf(alex, anna, areem, craig, john, paola) + val listContacts = persistentListOf(areem, ben, anna, john) + + val recipientTargets = persistentListOf( + target(alex), + target(anna), + target(areem), + target(craig), + target(john), + ) + val recipientInvoiceTargets = persistentListOf( + target(alex), + target(anna), + target(areem), + target(craig), + target(john), + target(paola), + ) + + fun listRequests(now: Instant) = persistentListOf( + request("incoming-areem", areem.publicKey, 21_000uL, "Lunch last week", now), + request("incoming-ben", ben.publicKey, 100_000uL, "Groceries", now), + request( + id = "outgoing-anna", + counterparty = anna.publicKey, + amountSats = 14_500uL, + note = "Snacks at conference", + createdAt = now, + direction = PaykitPaymentRequestDirection.Outgoing, + deliveryStatus = PaykitPaymentRequestDeliveryStatus.Sent, + ), + request( + id = "outgoing-john", + counterparty = john.publicKey, + amountSats = 50_000uL, + note = "Steaks & Burgers", + createdAt = now, + direction = PaykitPaymentRequestDirection.Outgoing, + deliveryStatus = PaykitPaymentRequestDeliveryStatus.Sent, + ), + ) + + fun listPending(now: Instant) = persistentListOf( + request("incoming-areem", areem.publicKey, 21_000uL, "Lunch last week", now), + request("incoming-ben", ben.publicKey, 100_000uL, "Groceries", now), + ) + + fun target(profile: PubkyProfile) = PaykitPaymentRequestTarget( + publicKey = profile.publicKey, + receiverPath = "bitkit/wallet", + ) + + fun request( + id: String, + counterparty: String, + amountSats: ULong, + note: String, + createdAt: Instant, + direction: PaykitPaymentRequestDirection = PaykitPaymentRequestDirection.Incoming, + deliveryStatus: PaykitPaymentRequestDeliveryStatus? = null, + ) = PaykitPaymentRequest( + paymentRequestId = id, + counterparty = counterparty, + counterpartyReceiverPath = "bitkit/wallet", + amountValue = "0", + amountSats = amountSats, + note = note, + createdAt = createdAt, + expiresAt = null, + acceptedPaymentEndpointIdentifiers = listOf("btc-lightning-bolt11"), + deliveryStatus = deliveryStatus, + direction = direction, + lifecycleState = PaymentRequestLifecycleState.PROPOSED, + ) + + private fun profile(publicKey: String, name: String) = + PubkyProfile.forDisplay(publicKey, name, null) +} diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestUiCaptureTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestUiCaptureTest.kt new file mode 100644 index 0000000000..7e95be1731 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestUiCaptureTest.kt @@ -0,0 +1,262 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.paymentrequests + +import android.graphics.Bitmap +import android.graphics.Color as AndroidColor +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Rule +import org.junit.Test +import to.bitkit.repositories.AmountInputHandler +import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus +import to.bitkit.repositories.PaykitPaymentRequestDirection +import to.bitkit.repositories.PaykitPaymentRequestDraft +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.viewmodels.AmountInputViewModel +import java.io.File +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.ExperimentalTime + +@ComposeUi +class PaymentRequestUiCaptureTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun captureList() { + val now = Clock.System.now() + captureScreen("list") { + PaymentRequestsContent( + requests = PaymentRequestFigmaFixtures.listRequests(now), + pending = PaymentRequestFigmaFixtures.listPending(now), + contacts = PaymentRequestFigmaFixtures.listContacts, + rejectingRequestIds = persistentSetOf(), + canRequestPayment = true, + onBack = {}, + onRequestPayment = {}, + onPay = {}, + onReject = {}, + ) + } + } + + @Test + fun captureRecipient() { + captureSheet("recipient") { modifier -> + PaymentRequestRecipientContent( + targets = PaymentRequestFigmaFixtures.recipientTargets, + contacts = PaymentRequestFigmaFixtures.recipientContacts, + isCreating = false, + onBack = {}, + onPaste = { "" }, + onSend = {}, + modifier = modifier + ) + } + } + + @Test + fun captureRecipientInvoice() { + captureSheet("recipient-invoice") { modifier -> + PaymentRequestRecipientContent( + targets = PaymentRequestFigmaFixtures.recipientInvoiceTargets, + contacts = PaymentRequestFigmaFixtures.recipientInvoiceContacts, + isCreating = false, + onBack = {}, + onPaste = { "" }, + onSend = {}, + showContactsHeader = false, + modifier = modifier + ) + } + } + + @Test + fun captureDetails() { + captureSheet("details") { modifier -> + PaymentRequestDetailsContent( + amountInputViewModel = AmountInputViewModel(AmountInputHandler.stub()), + initialDraft = PaykitPaymentRequestDraft( + amountSats = 14_500uL, + note = "Snacks at conference", + expiresAt = Clock.System.now() + 7.days, + ), + onBack = {}, + onContinue = {}, + modifier = modifier + ) + } + } + + @Test + fun captureDetailsContact() { + captureSheet("details-contact") { modifier -> + PaymentRequestDetailsContent( + amountInputViewModel = AmountInputViewModel(AmountInputHandler.stub()), + initialDraft = PaykitPaymentRequestDraft( + amountSats = 50_000uL, + note = "Steaks & Burgers", + expiresAt = Clock.System.now() + 7.days, + ), + onBack = {}, + onContinue = {}, + recipient = PaymentRequestFigmaFixtures.john, + modifier = modifier + ) + } + } + + @Test + fun captureAmountEmpty() { + setSheetContent { modifier -> + PaymentRequestDetailsContent( + amountInputViewModel = AmountInputViewModel(AmountInputHandler.stub()), + initialDraft = PaykitPaymentRequestDraft( + amountSats = 0uL, + note = "", + expiresAt = Clock.System.now() + 7.days, + ), + onBack = {}, + onContinue = {}, + modifier = modifier + ) + } + composeTestRule.onNodeWithTag("PaymentRequestEditAmount").performClick() + composeTestRule.waitForIdle() + save("amount-empty") + } + + @Test + fun captureAmountFilled() { + setSheetContent { modifier -> + PaymentRequestDetailsContent( + amountInputViewModel = AmountInputViewModel(AmountInputHandler.stub()), + initialDraft = PaykitPaymentRequestDraft( + amountSats = 50_000uL, + note = "Steaks & Burgers", + expiresAt = Clock.System.now() + 7.days, + ), + onBack = {}, + onContinue = {}, + modifier = modifier + ) + } + composeTestRule.onNodeWithTag("PaymentRequestEditAmount").performClick() + composeTestRule.waitForIdle() + save("amount-filled") + } + + @Test + fun captureSent() { + captureSheet("sent") { modifier -> + PaymentRequestSentContent( + request = PaymentRequestFigmaFixtures.request( + id = "outgoing-anna", + counterparty = PaymentRequestFigmaFixtures.anna.publicKey, + amountSats = 14_500uL, + note = "Snacks at conference", + createdAt = Clock.System.now(), + direction = PaykitPaymentRequestDirection.Outgoing, + deliveryStatus = PaykitPaymentRequestDeliveryStatus.Sent, + ), + contact = PaymentRequestFigmaFixtures.anna, + onDone = {}, + modifier = modifier + ) + } + } + + private fun captureScreen(name: String, content: @Composable () -> Unit) { + composeTestRule.runOnUiThread { + composeTestRule.activity.window.decorView.setBackgroundColor(AndroidColor.BLACK) + } + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Colors.Black), + ) { + content() + } + } + } + } + composeTestRule.waitForIdle() + save(name) + } + + private fun captureSheet(name: String, content: @Composable (Modifier) -> Unit) { + setSheetContent(content) + composeTestRule.waitForIdle() + save(name) + } + + private fun setSheetContent(content: @Composable (Modifier) -> Unit) { + composeTestRule.runOnUiThread { + composeTestRule.activity.window.decorView.setBackgroundColor(AndroidColor.BLACK) + } + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Colors.Black), + ) { + BottomSheetPreview { + content(Modifier.sheetHeight()) + } + } + } + } + } + composeTestRule.waitForIdle() + } + + private fun save(name: String) { + composeTestRule.waitForIdle() + val instrumentation = InstrumentationRegistry.getInstrumentation() + val bitmap = instrumentation.uiAutomation.takeScreenshot() + ?: composeTestRule.onRoot().captureToImage().asAndroidBitmap() + val context = instrumentation.targetContext + val dirs = listOfNotNull( + File("/data/local/tmp"), + context.getExternalFilesDir(null), + context.filesDir, + ) + var saved = false + dirs.forEach { dir -> + runCatching { + dir.mkdirs() + File(dir, "$name-after.png").outputStream().use { out -> + check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)) + } + saved = true + android.util.Log.e("PR_CAPTURE", "saved ${File(dir, "$name-after.png").absolutePath} w=${bitmap.width} h=${bitmap.height}") + } + } + check(saved) { "failed to save $name-after.png" } + } +} 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 35fd856b3d..d2b45eab55 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 @@ -107,8 +107,9 @@ 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("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..77fc271ebb --- /dev/null +++ b/app/src/debug/java/to/bitkit/viewmodels/PaymentRequestFixtureRuntime.kt @@ -0,0 +1,79 @@ +package to.bitkit.viewmodels + +import android.net.Uri +import com.synonym.paykit.PaymentRequestLifecycleState +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 48185:303457, 48185:303440, 48185:303376. */ +internal object PaymentRequestFixtureRuntime { + private const val HOST = "dev-fixture" + private const val PATH = "payment-request" + 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 fixtureFor(uri: Uri): PaymentRequestFixture? { + if (uri.scheme?.lowercase() != ScreenDeepLinks.SCHEME) return null + if (uri.host?.lowercase() != HOST) return null + if (uri.pathSegments.singleOrNull()?.lowercase() != PATH) return null + + 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/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index b34954e346..8c79801fef 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -746,6 +746,7 @@ private fun RootNavHost( appViewModel = appViewModel, onBack = { navController.popBackStack() }, onRequestPayment = { + navController.popBackStack() appViewModel.showSheet(Sheet.Receive(route = ReceiveRoute.PaymentRequestDetails)) }, ) 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 467404a6e2..5aea606c4d 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -45,25 +45,34 @@ fun MoneyDisplay( fun MoneyCell( sats: Long, modifier: Modifier = Modifier, + secondaryText: String? = null, ) { val currencies = LocalCurrencies.current Column( horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(2.dp), - modifier = modifier, + modifier = modifier ) { rememberMoneyText(sats = sats, unit = currencies.primaryDisplay, showSymbol = true)?.let { text -> BodyMSB( text = text.withAccent(accentColor = Colors.White64), - modifier = Modifier.testTag("MoneyPrimary"), + 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 37f30aea50..a91facd016 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 @@ -6,10 +6,13 @@ package to.bitkit.ui.screens.paymentrequests import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image 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 @@ -17,7 +20,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -34,7 +36,6 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -58,11 +59,13 @@ import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.FillWidth import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadTextField import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyContactRow import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.UnitButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.clickableAlpha @@ -82,6 +85,12 @@ import kotlin.time.Duration.Companion.hours import kotlin.time.ExperimentalTime import kotlin.time.Instant +/** Keypad height for four 88dp key rows, matching the Figma amount frame. */ +private val NUMBER_PAD_HEIGHT = 352.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), @@ -104,12 +113,16 @@ fun PaymentRequestDetailsScreen( initialDraft: PaykitPaymentRequestDraft, onBack: () -> Unit, onContinue: (PaykitPaymentRequestDraft) -> Unit, + recipient: PubkyProfile? = null, + isCreating: Boolean = false, ) { PaymentRequestDetailsContent( amountInputViewModel = amountInputViewModel, initialDraft = initialDraft, onBack = onBack, onContinue = onContinue, + recipient = recipient, + isCreating = isCreating, ) } @@ -120,6 +133,8 @@ internal fun PaymentRequestDetailsContent( initialDraft: PaykitPaymentRequestDraft, onBack: () -> Unit, onContinue: (PaykitPaymentRequestDraft) -> Unit, + recipient: PubkyProfile? = null, + isCreating: Boolean = false, ) { val currencies = LocalCurrencies.current val amountState by amountInputViewModel.uiState.collectAsStateWithLifecycle() @@ -135,138 +150,213 @@ internal fun PaymentRequestDetailsContent( currencies, ) } + BackHandler(enabled = isCreating) {} Column( modifier = modifier .fillMaxSize() .gradientBackground() .navigationBarsPadding() - .padding(horizontal = 16.dp) .testTag("PaymentRequestDetails") ) { SheetTopBar( - titleText = stringResource(R.string.wallet__payment_request), - onBack = onBack, + titleText = stringResource( + if (isEditingAmount) { + R.string.wallet__payment_request_amount + } else { + R.string.wallet__payment_request + } + ), + onBack = onBack.takeIf { isEditingAmount }, ) - Caption13Up(text = stringResource(R.string.wallet__payment_request_amount), color = Colors.White64) - VerticalSpacer(8.dp) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - NumberPadTextField( - viewModel = amountInputViewModel, - onClick = { isEditingAmount = true }, - modifier = Modifier - .weight(1f) - .testTag("PaymentRequestAmountField"), - ) - IconButton( - onClick = { isEditingAmount = true }, - modifier = Modifier - .size(48.dp) - .testTag("PaymentRequestEditAmount"), - ) { - Icon( - painter = painterResource(R.drawable.ic_pencil_simple), - contentDescription = stringResource(R.string.common__edit), - tint = Colors.White, - modifier = Modifier.size(24.dp), - ) - } - } - if (isEditingAmount) { - FillHeight() - NumberPad( - viewModel = amountInputViewModel, - availableHeight = 210.dp, - modifier = Modifier.testTag("PaymentRequestNumberPad"), - ) - VerticalSpacer(12.dp) - PrimaryButton( - text = stringResource(R.string.common__continue), - onClick = { isEditingAmount = false }, - modifier = Modifier.testTag("PaymentRequestAmountDone"), - ) - } else { - VerticalSpacer(20.dp) - Caption13Up(text = stringResource(R.string.wallet__payment_request_note), color = Colors.White64) - VerticalSpacer(8.dp) - TextInput( - value = note, - onValueChange = { note = it.take(256) }, - placeholder = stringResource(R.string.wallet__payment_request_note_placeholder), - maxLines = 2, - modifier = Modifier - .fillMaxWidth() - .testTag("PaymentRequestNote"), - ) - VerticalSpacer(20.dp) - Caption13Up(text = stringResource(R.string.wallet__payment_request_expires), color = Colors.White64) - VerticalSpacer(8.dp) - Row(modifier = Modifier.fillMaxWidth()) { - PaymentRequestExpiration.entries.forEach { option -> - val isSelected = option == expiration - Column( - horizontalAlignment = Alignment.CenterHorizontally, + BoxWithConstraints { + val maxHeight = this.maxHeight + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + VerticalSpacer(16.dp) + if (isEditingAmount) { + NumberPadTextField( + viewModel = amountInputViewModel, modifier = Modifier - .weight(1f) - .clickableAlpha { expiration = option } - .semantics { - role = Role.RadioButton - selected = isSelected - } - .testTag("PaymentRequestExpiry${option.name}"), + .fillMaxWidth() + .testTag("PaymentRequestAmountField") + ) + FillHeight(weight = 4f, min = 12.dp) + Row( + verticalAlignment = Alignment.Bottom, + modifier = Modifier.fillMaxWidth() ) { - BodyS(text = option.title(), color = if (isSelected) Colors.White else Colors.White64) - VerticalSpacer(8.dp) - HorizontalDivider( - thickness = 2.dp, - color = if (isSelected) Colors.White else Colors.White16, + FillWidth() + UnitButton( + onClick = { amountInputViewModel.switchUnit(currencies) }, + modifier = Modifier.testTag("PaymentRequestNumberPadUnit") ) } - } - } - FillHeight() - PrimaryButton( - text = stringResource(R.string.wallet__payment_request_choose_recipient), - enabled = amountState.sats > 0, - onClick = { - onContinue( - PaykitPaymentRequestDraft( - amountSats = amountState.sats.toULong(), - note = note.trim(), - expiresAt = Clock.System.now() + expiration.duration, + VerticalSpacer(12.dp) + HorizontalDivider() + VerticalSpacer(16.dp) + NumberPad( + viewModel = amountInputViewModel, + currencies = currencies, + modifier = Modifier + .height(NUMBER_PAD_HEIGHT) + .testTag("PaymentRequestNumberPad") + ) + FillHeight(min = 12.dp) + PrimaryButton( + text = stringResource(R.string.common__continue), + enabled = amountState.sats > 0, + onClick = { isEditingAmount = false }, + modifier = Modifier.testTag("PaymentRequestAmountDone") + ) + } else { + Caption13Up( + text = stringResource(R.string.wallet__payment_request_amount), + color = Colors.White64, + ) + VerticalSpacer(16.dp) + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + NumberPadTextField( + viewModel = amountInputViewModel, + showSecondaryField = false, + onClick = { isEditingAmount = true }, + modifier = Modifier + .weight(1f) + .testTag("PaymentRequestAmountField") + ) + Icon( + painter = painterResource(R.drawable.ic_pencil_simple), + contentDescription = stringResource(R.string.common__edit), + tint = Colors.White, + modifier = Modifier + .size(24.dp) + .clickableAlpha { isEditingAmount = true } + .testTag("PaymentRequestEditAmount") ) + } + VerticalSpacer(24.dp) + Caption13Up( + text = stringResource(R.string.wallet__payment_request_note), + color = Colors.White64, ) - }, - modifier = Modifier.testTag("PaymentRequestAmountContinue"), - ) + VerticalSpacer(8.dp) + TextInput( + value = note, + onValueChange = { note = it.take(256) }, + placeholder = stringResource(R.string.wallet__payment_request_note_placeholder), + minLines = 1, + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .testTag("PaymentRequestNote") + ) + if (recipient != null) { + VerticalSpacer(30.dp) + Caption13Up( + text = stringResource(R.string.wallet__payment_request_recipient), + color = Colors.White64, + ) + VerticalSpacer(12.dp) + PaymentRequestCard( + request = PaykitPaymentRequest( + paymentRequestId = "preview", + counterparty = recipient.publicKey, + counterpartyReceiverPath = "", + amountValue = amountState.sats.toString(), + amountSats = amountState.sats.toULong(), + note = note.trim().ifBlank { null }, + expiresAt = Clock.System.now() + expiration.duration, + acceptedPaymentEndpointIdentifiers = emptyList(), + ), + contact = recipient, + compactSubtitle = note.trim().ifBlank { recipient.name }, + ) + } + VerticalSpacer(30.dp) + Caption13Up( + text = stringResource(R.string.wallet__payment_request_expires), + color = Colors.White64, + ) + VerticalSpacer(20.dp) + Row(modifier = Modifier.fillMaxWidth()) { + PaymentRequestExpiration.entries.forEach { option -> + val isSelected = option == expiration + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .clickableAlpha { expiration = option } + .semantics { + role = Role.RadioButton + selected = isSelected + } + .testTag("PaymentRequestExpiry${option.name}") + ) { + BodyS( + text = option.title(), + color = if (isSelected) Colors.White else Colors.White64, + ) + VerticalSpacer(12.dp) + HorizontalDivider( + thickness = 2.dp, + color = if (isSelected) Colors.White else Colors.White16, + ) + } + } + } + FillHeight() + PrimaryButton( + text = stringResource( + if (recipient != null) { + R.string.wallet__payment_request_send_request + } else { + R.string.wallet__payment_request_choose_recipient + } + ), + enabled = amountState.sats > 0 && !isCreating, + isLoading = isCreating, + onClick = { + onContinue( + PaykitPaymentRequestDraft( + amountSats = amountState.sats.toULong(), + note = note.trim(), + expiresAt = Clock.System.now() + expiration.duration, + ) + ) + }, + modifier = Modifier.testTag( + if (recipient != null) "PaymentRequestSend" else "PaymentRequestAmountContinue" + ) + ) + } + VerticalSpacer(22.dp) + } } - VerticalSpacer(16.dp) } } @Composable fun PaymentRequestRecipientScreen( appViewModel: AppViewModel, - draft: PaykitPaymentRequestDraft, - onEditExpiration: () -> Unit, - onSent: (PaykitPaymentRequest) -> Unit, + onBack: () -> Unit, + onRecipientSelected: (PaykitPaymentRequestTarget) -> Unit, + showContactsHeader: Boolean = true, ) { val context = LocalContext.current val targets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() - val isCreating by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() PaymentRequestRecipientContent( targets = targets.toImmutableList(), contacts = contacts.toImmutableList(), - isCreating = isCreating, - onEditExpiration = onEditExpiration, + isCreating = false, + onBack = onBack, onPaste = { context.getClipboardText()?.trim().orEmpty() }, - onSend = { target -> appViewModel.createPaymentRequest(draft, target, onSent) }, + onSend = onRecipientSelected, + showContactsHeader = showContactsHeader, ) } @@ -276,11 +366,11 @@ internal fun PaymentRequestRecipientContent( targets: ImmutableList, contacts: ImmutableList, isCreating: Boolean, - onEditExpiration: () -> Unit, + onBack: () -> Unit, onPaste: () -> String, onSend: (PaykitPaymentRequestTarget) -> Unit, + showContactsHeader: Boolean = true, ) { - var selectedTarget by remember { mutableStateOf(null) } var query by remember { mutableStateOf("") } val recipients = remember(targets, contacts, query) { @@ -294,9 +384,6 @@ internal fun PaymentRequestRecipientContent( } } - LaunchedEffect(recipients) { - if (recipients.none { (target, _) -> target == selectedTarget }) selectedTarget = null - } BackHandler(enabled = isCreating) {} Column( @@ -304,63 +391,62 @@ internal fun PaymentRequestRecipientContent( .fillMaxSize() .gradientBackground() .navigationBarsPadding() - .padding(horizontal = 16.dp) .testTag("PaymentRequestRecipient") ) { SheetTopBar( titleText = stringResource(R.string.wallet__payment_request_choose_recipient), - action = { - IconButton( - onClick = onEditExpiration, - enabled = !isCreating, - modifier = Modifier.testTag("PaymentRequestEditExpiration"), - ) { - Icon( - painter = painterResource(R.drawable.ic_timer), - contentDescription = stringResource(R.string.wallet__payment_request_edit_expiration), - tint = Colors.White, - modifier = Modifier.size(24.dp), - ) - } - }, - ) - Caption13Up(text = stringResource(R.string.wallet__payment_request_recipient), color = Colors.White64) - VerticalSpacer(8.dp) - TextInput( - value = query, - onValueChange = { query = it }, - placeholder = stringResource(R.string.wallet__payment_request_enter_pubky), - singleLine = true, - textStyle = AppTextStyles.BodyM, - trailingIcon = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .clickableAlpha { - query = PubkyPublicKeyFormat.bounded(onPaste()) - } - .padding(horizontal = 12.dp) - .testTag("PaymentRequestRecipientPaste"), - ) { - Icon( - painter = painterResource(R.drawable.ic_clipboard_text), - contentDescription = null, - tint = Colors.White, - modifier = Modifier.size(24.dp), - ) - BodyMSB(text = stringResource(R.string.wallet__payment_request_paste)) - } - }, - modifier = Modifier - .fillMaxWidth() - .testTag("PaymentRequestRecipientSearch"), + onBack = onBack, ) - VerticalSpacer(24.dp) - Caption13Up(text = stringResource(R.string.contacts__contacts_header), color = Colors.White64) - VerticalSpacer(8.dp) - HorizontalDivider(color = Colors.White10) - LazyColumn(modifier = Modifier.weight(1f)) { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + VerticalSpacer(16.dp) + Caption13Up(text = stringResource(R.string.wallet__payment_request_recipient), color = Colors.White64) + VerticalSpacer(8.dp) + TextInput( + value = query, + onValueChange = { query = it }, + placeholder = stringResource(R.string.wallet__payment_request_enter_pubky), + singleLine = true, + textStyle = AppTextStyles.BodyM, + trailingIcon = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .clickableAlpha { + query = PubkyPublicKeyFormat.bounded(onPaste()) + } + .padding(horizontal = 12.dp) + .testTag("PaymentRequestRecipientPaste") + ) { + Icon( + painter = painterResource(R.drawable.ic_clipboard_text), + contentDescription = null, + tint = Colors.White, + modifier = Modifier.size(24.dp) + ) + BodyMSB(text = stringResource(R.string.wallet__payment_request_paste)) + } + }, + modifier = Modifier + .fillMaxWidth() + .testTag("PaymentRequestRecipientSearch") + ) + VerticalSpacer(16.dp) + } + if (showContactsHeader) { + Caption13Up( + text = stringResource(R.string.contacts__contacts_header), + color = Colors.White64, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 20.dp) + .testTag("PaymentRequestContactsHeader") + ) + } + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + LazyColumn( + contentPadding = PaddingValues(top = 6.dp), + modifier = Modifier.weight(1f) + ) { if (recipients.isEmpty()) { item { BodyM( @@ -372,11 +458,10 @@ internal fun PaymentRequestRecipientContent( } ), color = Colors.White64, - textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() - .padding(top = 24.dp) - .testTag("PaymentRequestRecipientUnavailable"), + .padding(horizontal = 16.dp, vertical = 16.dp) + .testTag("PaymentRequestRecipientUnavailable") ) } } @@ -386,27 +471,16 @@ internal fun PaymentRequestRecipientContent( ) { (target, contact) -> PubkyContactRow( profile = contact, - onClick = { selectedTarget = target }, - isSelected = target == selectedTarget, + onClick = { onSend(target) }, isEnabled = !isCreating, - verticalPadding = 16.dp, - selectionColor = Colors.Brand, - modifier = Modifier.testTag("PaymentRequestContact${contact.publicKey}"), + verticalPadding = 30.dp, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag("PaymentRequestContact${contact.publicKey}") ) - HorizontalDivider(color = Colors.White10) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) } } - PrimaryButton( - text = stringResource(R.string.wallet__payment_request_send_request), - enabled = !isCreating && selectedTarget != null && selectedTarget in targets, - isLoading = isCreating, - onClick = { - val target = selectedTarget ?: return@PrimaryButton - onSend(target) - }, - modifier = Modifier.testTag("PaymentRequestSend"), - ) - VerticalSpacer(16.dp) } } @@ -429,52 +503,59 @@ internal fun PaymentRequestSentContent( onDone: () -> Unit, ) { Column( - horizontalAlignment = Alignment.Start, modifier = modifier .fillMaxSize() .gradientBackground() .navigationBarsPadding() - .padding(horizontal = 16.dp) - .testTag("PaymentRequestSent"), + .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(R.string.wallet__payment_request_sent_description), - color = Colors.White64, - textAlign = TextAlign.Start, - modifier = Modifier.fillMaxWidth(), - ) - VerticalSpacer(24.dp) - PaymentRequestCard( - request = request, - contact = contact, - compactSubtitle = 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.fillMaxSize()) { + val checkSize = maxHeight * SENT_CHECK_HEIGHT_FRACTION + Column( + horizontalAlignment = Alignment.Start, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + 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(R.string.wallet__payment_request_sent_description), + color = Colors.White64, + ) + VerticalSpacer(16.dp) + PaymentRequestCard( + request = request, + contact = contact, + compactSubtitle = request.note?.takeIf { it.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) + } + } } } @@ -521,7 +602,24 @@ private fun PaymentRequestDetailsPreview() { initialDraft = previewDraft, onBack = {}, onContinue = {}, - modifier = Modifier.sheetHeight(), + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PaymentRequestDetailsContactPreview() { + AppThemeSurface { + BottomSheetPreview { + PaymentRequestDetailsContent( + amountInputViewModel = AmountInputViewModel(AmountInputHandler.stub()), + initialDraft = previewDraft, + onBack = {}, + onContinue = {}, + recipient = PubkyProfile.placeholder(previewTarget.publicKey), + modifier = Modifier.sheetHeight() ) } } @@ -536,10 +634,10 @@ private fun PaymentRequestRecipientPreview() { targets = persistentListOf(previewTarget), contacts = persistentListOf(PubkyProfile.placeholder(previewTarget.publicKey)), isCreating = false, - onEditExpiration = {}, + onBack = {}, onPaste = { "" }, onSend = {}, - modifier = Modifier.sheetHeight(), + modifier = Modifier.sheetHeight() ) } } @@ -554,7 +652,7 @@ private fun PaymentRequestSentPreview() { request = previewCreatedRequest, contact = PubkyProfile.placeholder(previewTarget.publicKey), onDone = {}, - modifier = Modifier.sheetHeight(), + 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 abdab879f5..1e142583a8 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 @@ -129,12 +129,12 @@ internal fun PaymentRequestsSheetContent( BodyM( text = stringResource(R.string.wallet__payment_requests_review), color = Colors.White64, - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = Modifier.align(Alignment.CenterHorizontally) ) VerticalSpacer(24.dp) LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f) ) { items(requests.take(3), key = { it.lazyListKey }) { request -> PaymentRequestCard( @@ -150,14 +150,14 @@ internal fun PaymentRequestsSheetContent( SecondaryButton( text = stringResource(R.string.wallet__payment_requests_not_now), onClick = onNotNow, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f) ) PrimaryButton( text = stringResource(R.string.wallet__payment_requests_see_all), onClick = onSeeAll, modifier = Modifier .weight(1f) - .testTag("PaymentRequestsSeeAll"), + .testTag("PaymentRequestsSeeAll") ) } VerticalSpacer(16.dp) @@ -207,7 +207,6 @@ internal fun PaymentRequestsContent( Column( modifier = modifier .fillMaxSize() - .gradientBackground() .navigationBarsPadding() .testTag("PaymentRequestsScreen") ) { @@ -220,7 +219,7 @@ internal fun PaymentRequestsContent( Column( modifier = Modifier .weight(1f) - .padding(horizontal = 16.dp), + .padding(horizontal = 16.dp) ) { FillHeight() Image( @@ -229,7 +228,7 @@ internal fun PaymentRequestsContent( modifier = Modifier .size(256.dp) .align(Alignment.CenterHorizontally) - .testTag("PaymentRequestsEmptyIllustration"), + .testTag("PaymentRequestsEmptyIllustration") ) FillHeight() Display( @@ -245,27 +244,45 @@ internal fun PaymentRequestsContent( } } else { LazyColumn( - contentPadding = PaddingValues(top = 24.dp, bottom = 16.dp), + contentPadding = PaddingValues(top = 16.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .weight(1f) - .padding(horizontal = 16.dp), + .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, + modifier = Modifier.padding(top = 12.dp) ) } - 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), isRejecting = request.id in rejectingRequestIds, + onPay = { onPay(request.id) }, + onReject = { onReject(request) }, + ) + } + } + if (sections.outgoing.isNotEmpty()) { + item { + Caption13Up( + text = stringResource(R.string.wallet__payment_requests_outgoing), + color = Colors.White64, + modifier = Modifier.padding(top = 24.dp) + ) + } + items(sections.outgoing, key = { it.lazyListKey }) { request -> + PaymentRequestCard( + request = request, contact = contacts.contactFor(request), - onPay = onPay, - onReject = onReject, + compactSubtitle = request.note?.takeIf { it.isNotBlank() } + ?: paymentRequestStatus(request), + secondaryText = stringResource(R.string.wallet__payment_request_pending), ) } } @@ -280,7 +297,8 @@ internal fun PaymentRequestsContent( PaymentRequestCard( request = request, contact = contacts.contactFor(request), - compactSubtitle = paymentRequestDate(request), + compactSubtitle = request.note?.takeIf { it.isNotBlank() } + ?: paymentRequestDate(request), ) } } @@ -293,7 +311,7 @@ internal fun PaymentRequestsContent( onClick = onRequestPayment, modifier = Modifier .padding(horizontal = 16.dp) - .testTag("PaymentRequestCreate"), + .testTag("PaymentRequestCreate") ) VerticalSpacer(16.dp) } @@ -301,7 +319,8 @@ internal fun PaymentRequestsContent( } private data class PaymentRequestSections( - val active: List, + val incoming: List, + val outgoing: List, val history: List, ) @@ -325,13 +344,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) } @@ -339,37 +359,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, - isRejecting: Boolean, - contact: PubkyProfile?, - onPay: (PaykitPaymentRequestId) -> Unit, - onReject: (PaykitPaymentRequest) -> Unit, -) { - if (isIncoming) { - PaymentRequestCard( - request = request, - contact = contact, - compactSubtitle = paymentRequestDateTime(request), - isRejecting = isRejecting, - onPay = { onPay(request.id) }, - onReject = { onReject(request) }, - ) - } else { - PaymentRequestCard( - request = request, - contact = contact, - compactSubtitle = stringResource( - R.string.wallet__payment_request_waiting_for_recipient, - contact?.name ?: PubkyProfile.placeholder(request.counterparty).name, - ), - ) - } + return PaymentRequestSections(incoming, outgoing, history) } @Composable @@ -408,16 +398,6 @@ private fun paymentRequestDate(request: PaykitPaymentRequest): String = request. uiDateText(it.epochSeconds.toULong(), UiDateStyle.DATE) } ?: paymentRequestStatus(request) -@Composable -private fun paymentRequestDateTime(request: PaykitPaymentRequest): String = request.createdAt?.let { - val timestamp = it.epochSeconds.toULong() - stringResource( - R.string.wallet__payment_request_timestamp, - uiDateText(timestamp, UiDateStyle.DATE), - uiDateText(timestamp, UiDateStyle.TIME), - ) -} ?: paymentRequestStatus(request) - @Composable private fun paymentRequestStatus(request: PaykitPaymentRequest): String { if (request.lifecycleState == PaymentRequestLifecycleState.PROPOSED && request.isExpired(Clock.System.now())) { @@ -458,18 +438,23 @@ internal fun PaymentRequestCard( request: PaykitPaymentRequest, contact: PubkyProfile?, compactSubtitle: String? = null, + secondaryText: String? = null, isRejecting: Boolean = false, onPay: (() -> Unit)? = null, onReject: (() -> Unit)? = null, ) { val displayContact = contact ?: PubkyProfile.placeholder(request.counterparty) - val subtitle = compactSubtitle ?: request.createdAt?.let { - val timestamp = it.epochSeconds.toULong() - val date = uiDateText(timestamp, UiDateStyle.DATE) - val time = uiDateText(timestamp, UiDateStyle.TIME) - val formattedTimestamp = stringResource(R.string.wallet__payment_request_timestamp, date, time) - stringResource(R.string.wallet__payment_request_contact_timestamp, displayContact.name, formattedTimestamp) - } ?: displayContact.name + val subtitle = compactSubtitle + ?: request.note?.takeIf { it.isNotBlank() } + ?: request.createdAt?.let { + val timestamp = it.epochSeconds.toULong() + stringResource( + R.string.wallet__payment_request_timestamp, + uiDateText(timestamp, UiDateStyle.DATE), + uiDateText(timestamp, UiDateStyle.TIME), + ) + } + ?: displayContact.name Card( colors = CardDefaults.cardColors(containerColor = Colors.Gray6), @@ -482,7 +467,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) @@ -490,17 +475,17 @@ internal fun PaymentRequestCard( Modifier } ) - .testTag("PaymentRequestRow${request.paymentRequestId}"), + .testTag("PaymentRequestRow${request.paymentRequestId}") ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(16.dp) ) { - PubkyContactAvatar(profile = displayContact, size = 40.dp) + PubkyContactAvatar(profile = displayContact) Column(modifier = Modifier.weight(1f)) { BodyMSB( - text = request.note ?: stringResource(R.string.wallet__payment_request), + text = displayContact.name, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -513,6 +498,7 @@ internal fun PaymentRequestCard( } MoneyCell( sats = request.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + secondaryText = secondaryText, ) } if (onPay != null || onReject != null) { @@ -521,7 +507,7 @@ internal fun PaymentRequestCard( modifier = Modifier .fillMaxWidth() .background(Colors.Gray5) - .padding(16.dp), + .padding(16.dp) ) { SecondaryButton( text = stringResource(R.string.wallet__payment_request_dismiss), @@ -535,11 +521,11 @@ internal fun PaymentRequestCard( Icon( painter = painterResource(R.drawable.ic_x), contentDescription = null, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(16.dp) ) }, size = ButtonSize.Small, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f) ) PrimaryButton( text = stringResource(R.string.wallet__payment_request_pay), @@ -549,11 +535,11 @@ internal fun PaymentRequestCard( Icon( painter = painterResource(R.drawable.ic_coins), contentDescription = null, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(16.dp) ) }, size = ButtonSize.Small, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f) ) } } 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 020aba5a78..6ad6b77d9c 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 @@ -25,9 +25,12 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import kotlinx.serialization.Serializable import to.bitkit.R +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.LightningState import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDraft +import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.WalletState import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.navigateTo @@ -82,6 +85,14 @@ fun ReceiveSheet( ) } var createdPaymentRequest by remember { mutableStateOf(null) } + var paymentRequestTarget by remember { mutableStateOf(null) } + var showRecipientContactsHeader by remember { mutableStateOf(true) } + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val isCreatingPaymentRequest by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() + val paymentRequestRecipient = paymentRequestTarget?.let { target -> + contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, target.publicKey) } + ?: PubkyProfile.placeholder(target.publicKey) + } LaunchedEffect(Unit) { wallet.resetPreActivityMetadataTagsForCurrentInvoice() @@ -91,13 +102,13 @@ fun ReceiveSheet( Box( modifier = Modifier .fillMaxWidth() - .sheetHeight(), + .sheetHeight() ) { Column( modifier = Modifier .fillMaxSize() .imePadding() - .testTag("ReceiveScreen"), + .testTag("ReceiveScreen") ) { NavHost( navController = navController, @@ -127,37 +138,37 @@ fun ReceiveSheet( PaymentRequestDetailsScreen( amountInputViewModel = paymentRequestAmountViewModel, initialDraft = paymentRequestDraft, + recipient = paymentRequestRecipient, + isCreating = isCreatingPaymentRequest, onBack = { if (!navController.popBackStack()) appViewModel.hideSheet() }, - onContinue = { - paymentRequestDraft = it - navController.navigateTo(ReceiveRoute.PaymentRequestRecipient) - }, - ) - } - composableWithDefaultTransitions { - PaymentRequestDetailsScreen( - amountInputViewModel = paymentRequestAmountViewModel, - initialDraft = paymentRequestDraft, - onBack = { navController.popBackStack() }, - onContinue = { - paymentRequestDraft = it - navController.popBackStack() + onContinue = { draft -> + paymentRequestDraft = draft + val target = paymentRequestTarget + if (target != null) { + appViewModel.createPaymentRequest(draft, target) { + createdPaymentRequest = it + navController.navigateToPaymentRequestSent() + } + } else { + showRecipientContactsHeader = true + navController.navigateTo(ReceiveRoute.PaymentRequestRecipient) + } }, ) } composableWithDefaultTransitions { PaymentRequestRecipientScreen( appViewModel = appViewModel, - draft = paymentRequestDraft, - onEditExpiration = { - navController.navigateTo(ReceiveRoute.PaymentRequestExpiration) - }, - onSent = { - createdPaymentRequest = it - navController.navigateToPaymentRequestSent() + onBack = { navController.popBackStack() }, + onRecipientSelected = { target -> + paymentRequestTarget = target + navController.navigateTo(ReceiveRoute.PaymentRequestDetails) { + popUpTo(ReceiveRoute.PaymentRequestRecipient) { inclusive = true } + } }, + showContactsHeader = showRecipientContactsHeader, ) } composableWithDefaultTransitions { @@ -272,6 +283,7 @@ fun ReceiveSheet( note = note, expiresAt = Clock.System.now() + 7.days, ) + showRecipientContactsHeader = false navController.navigateTo(ReceiveRoute.PaymentRequestRecipient) }, navigateReceiveConfirm = { entry -> @@ -338,9 +350,6 @@ sealed interface ReceiveRoute { @Serializable data object PaymentRequestDetails : InternalOnly - @Serializable - data object PaymentRequestExpiration : InternalOnly - @Serializable data object PaymentRequestRecipient : InternalOnly diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index cb89803599..7e7bd8c50c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -318,13 +318,14 @@ 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() - 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 @@ -367,6 +368,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 } @@ -4382,6 +4387,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) @@ -4496,6 +4505,12 @@ class AppViewModel @Inject constructor( } private fun processDeeplink(uri: Uri) = viewModelScope.launch { + PaymentRequestFixtureRuntime.fixtureFor(uri)?.let { fixture -> + settingsStore.setIsPaykitEnabled(true) + paymentRequestFixture.update { fixture } + return@launch + } + 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..55b39a0a7b --- /dev/null +++ b/app/src/main/java/to/bitkit/viewmodels/PaymentRequestFixture.kt @@ -0,0 +1,34 @@ +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, + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cce3f106cd..6ec1e1cdd8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1199,6 +1199,7 @@ What is this payment for? Paste Pay + pending Your payment request is queued and will send automatically RECIPIENT No matching saved contact with a private connection. @@ -1220,18 +1221,18 @@ %1$s at %2$s 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..366eea01cf --- /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 fixtureFor(uri: Uri): PaymentRequestFixture? = 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