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..6e619a63eb 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,85 @@ 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, + onBack = {}, onEditExpiration = {}, - onPaste = { target.publicKey }, + 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("PaymentRequestContactsHeader").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestEditExpiration").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestRecipientPaste", useUnmergedTree = true).assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestSend").assertIsDisplayed() 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 = {}, + onEditExpiration = {}, + 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 +147,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..ddb46d7f2f --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestUiCaptureTest.kt @@ -0,0 +1,264 @@ +@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 = {}, + onEditExpiration = {}, + onPaste = { "" }, + onSend = {}, + modifier = modifier + ) + } + } + + @Test + fun captureRecipientInvoice() { + captureSheet("recipient-invoice") { modifier -> + PaymentRequestRecipientContent( + targets = PaymentRequestFigmaFixtures.recipientInvoiceTargets, + contacts = PaymentRequestFigmaFixtures.recipientInvoiceContacts, + isCreating = false, + onBack = {}, + onEditExpiration = {}, + 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/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..47d293dc43 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,6 +6,7 @@ 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.Row import androidx.compose.foundation.layout.fillMaxSize @@ -34,7 +35,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 +58,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 @@ -104,12 +106,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 +126,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 +143,211 @@ 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), + titleText = stringResource( + if (isEditingAmount) { + R.string.wallet__payment_request_amount + } else { + R.string.wallet__payment_request + } + ), onBack = onBack, ) - 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(min = 12.dp) + Row( + verticalAlignment = Alignment.Bottom, + modifier = Modifier.fillMaxWidth() ) { - BodyS(text = option.title(), color = if (isSelected) Colors.White else Colors.White64) + FillWidth() + UnitButton( + onClick = { amountInputViewModel.switchUnit(currencies) }, + modifier = Modifier.testTag("PaymentRequestNumberPadUnit") + ) + } + VerticalSpacer(16.dp) + HorizontalDivider() + NumberPad( + viewModel = amountInputViewModel, + currencies = currencies, + availableHeight = maxHeight, + modifier = Modifier.testTag("PaymentRequestNumberPad") + ) + 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(8.dp) + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + NumberPadTextField( + viewModel = amountInputViewModel, + 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(16.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), + minLines = 1, + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .testTag("PaymentRequestNote") + ) + if (recipient != null) { + VerticalSpacer(16.dp) + Caption13Up( + text = stringResource(R.string.wallet__payment_request_recipient), + color = Colors.White64, + ) VerticalSpacer(8.dp) - HorizontalDivider( - thickness = 2.dp, - color = if (isSelected) Colors.White else Colors.White16, + 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 }, ) } - } - } - 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(16.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, + 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(8.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" ) ) - }, - modifier = Modifier.testTag("PaymentRequestAmountContinue"), - ) + } + VerticalSpacer(16.dp) + } } - VerticalSpacer(16.dp) } } @Composable fun PaymentRequestRecipientScreen( appViewModel: AppViewModel, - draft: PaykitPaymentRequestDraft, + onBack: () -> Unit, onEditExpiration: () -> Unit, - onSent: (PaykitPaymentRequest) -> 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, + isCreating = false, + onBack = onBack, onEditExpiration = onEditExpiration, onPaste = { context.getClipboardText()?.trim().orEmpty() }, - onSend = { target -> appViewModel.createPaymentRequest(draft, target, onSent) }, + onSend = onRecipientSelected, + showContactsHeader = showContactsHeader, ) } @@ -276,9 +357,11 @@ internal fun PaymentRequestRecipientContent( targets: ImmutableList, contacts: ImmutableList, isCreating: Boolean, + onBack: () -> Unit, onEditExpiration: () -> Unit, onPaste: () -> String, onSend: (PaykitPaymentRequestTarget) -> Unit, + showContactsHeader: Boolean = true, ) { var selectedTarget by remember { mutableStateOf(null) } var query by remember { mutableStateOf("") } @@ -304,62 +387,72 @@ internal fun PaymentRequestRecipientContent( .fillMaxSize() .gradientBackground() .navigationBarsPadding() - .padding(horizontal = 16.dp) .testTag("PaymentRequestRecipient") ) { SheetTopBar( titleText = stringResource(R.string.wallet__payment_request_choose_recipient), + onBack = onBack, action = { IconButton( onClick = onEditExpiration, enabled = !isCreating, - modifier = Modifier.testTag("PaymentRequestEditExpiration"), + 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), + 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"), - ) - VerticalSpacer(24.dp) - Caption13Up(text = stringResource(R.string.contacts__contacts_header), color = Colors.White64) - VerticalSpacer(8.dp) - HorizontalDivider(color = Colors.White10) + 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 = 16.dp) + .testTag("PaymentRequestContactsHeader") + ) + } + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) LazyColumn(modifier = Modifier.weight(1f)) { if (recipients.isEmpty()) { item { @@ -372,11 +465,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") ) } } @@ -389,11 +481,11 @@ internal fun PaymentRequestRecipientContent( onClick = { selectedTarget = target }, isSelected = target == selectedTarget, isEnabled = !isCreating, - verticalPadding = 16.dp, - selectionColor = Colors.Brand, - modifier = Modifier.testTag("PaymentRequestContact${contact.publicKey}"), + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag("PaymentRequestContact${contact.publicKey}") ) - HorizontalDivider(color = Colors.White10) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) } } PrimaryButton( @@ -404,7 +496,9 @@ internal fun PaymentRequestRecipientContent( val target = selectedTarget ?: return@PrimaryButton onSend(target) }, - modifier = Modifier.testTag("PaymentRequestSend"), + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag("PaymentRequestSend") ) VerticalSpacer(16.dp) } @@ -429,52 +523,56 @@ 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, + Column( + horizontalAlignment = Alignment.Start, 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) + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + FillHeight() + 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(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(32.dp) + PrimaryButton( + text = stringResource(R.string.common__ok), + onClick = onDone, + ) + VerticalSpacer(16.dp) + } } } @@ -521,7 +619,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 +651,11 @@ private fun PaymentRequestRecipientPreview() { targets = persistentListOf(previewTarget), contacts = persistentListOf(PubkyProfile.placeholder(previewTarget.publicKey)), isCreating = false, + onBack = {}, onEditExpiration = {}, onPaste = { "" }, onSend = {}, - modifier = Modifier.sheetHeight(), + modifier = Modifier.sheetHeight() ) } } @@ -554,7 +670,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..b8ce4985dd 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( @@ -249,23 +248,39 @@ internal fun PaymentRequestsContent( 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, ) } - 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, + ) + } + 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 +295,8 @@ internal fun PaymentRequestsContent( PaymentRequestCard( request = request, contact = contacts.contactFor(request), - compactSubtitle = paymentRequestDate(request), + compactSubtitle = request.note?.takeIf { it.isNotBlank() } + ?: paymentRequestDate(request), ) } } @@ -293,15 +309,15 @@ internal fun PaymentRequestsContent( onClick = onRequestPayment, modifier = Modifier .padding(horizontal = 16.dp) - .testTag("PaymentRequestCreate"), + .testTag("PaymentRequestCreate") ) - VerticalSpacer(16.dp) } } } private data class PaymentRequestSections( - val active: List, + val incoming: List, + val outgoing: List, val history: List, ) @@ -325,13 +341,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 +356,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 +395,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 +435,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 +464,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 +472,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 +495,7 @@ internal fun PaymentRequestCard( } MoneyCell( sats = request.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + secondaryText = secondaryText, ) } if (onPay != null || onReject != null) { @@ -521,7 +504,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 +518,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 +532,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..a262b33571 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,12 +138,23 @@ fun ReceiveSheet( PaymentRequestDetailsScreen( amountInputViewModel = paymentRequestAmountViewModel, initialDraft = paymentRequestDraft, + recipient = paymentRequestRecipient, + isCreating = isCreatingPaymentRequest, onBack = { if (!navController.popBackStack()) appViewModel.hideSheet() }, - onContinue = { - paymentRequestDraft = it - navController.navigateTo(ReceiveRoute.PaymentRequestRecipient) + 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) + } }, ) } @@ -140,6 +162,8 @@ fun ReceiveSheet( PaymentRequestDetailsScreen( amountInputViewModel = paymentRequestAmountViewModel, initialDraft = paymentRequestDraft, + recipient = paymentRequestRecipient, + isCreating = isCreatingPaymentRequest, onBack = { navController.popBackStack() }, onContinue = { paymentRequestDraft = it @@ -150,14 +174,17 @@ fun ReceiveSheet( composableWithDefaultTransitions { PaymentRequestRecipientScreen( appViewModel = appViewModel, - draft = paymentRequestDraft, + onBack = { navController.popBackStack() }, onEditExpiration = { navController.navigateTo(ReceiveRoute.PaymentRequestExpiration) }, - onSent = { - createdPaymentRequest = it - navController.navigateToPaymentRequestSent() + onRecipientSelected = { target -> + paymentRequestTarget = target + navController.navigateTo(ReceiveRoute.PaymentRequestDetails) { + popUpTo(ReceiveRoute.PaymentRequestRecipient) { inclusive = true } + } }, + showContactsHeader = showRecipientContactsHeader, ) } composableWithDefaultTransitions { @@ -272,6 +299,7 @@ fun ReceiveSheet( note = note, expiresAt = Clock.System.now() + 7.days, ) + showRecipientContactsHeader = false navController.navigateTo(ReceiveRoute.PaymentRequestRecipient) }, navigateReceiveConfirm = { entry -> 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/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