From 2e11cecbeb0043766a37952d7ed825067ca0f3ef Mon Sep 17 00:00:00 2001 From: rasputin Date: Sat, 13 Jun 2026 17:50:17 +0200 Subject: [PATCH] refactor: extract PaymentSuccessContent --- .../bitcointxoko/gudariwallet/ui/UiStates.kt | 3 +- .../ui/common/PaymentSuccessContent.kt | 119 ++++++++++++++++++ .../gudariwallet/ui/receive/ReceiveScreen.kt | 69 +++------- .../gudariwallet/ui/send/SendScreen.kt | 62 +++------ .../gudariwallet/ui/send/SendViewModel.kt | 13 +- 5 files changed, 163 insertions(+), 103 deletions(-) create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/common/PaymentSuccessContent.kt diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/UiStates.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/UiStates.kt index cde5604..4369b7d 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/UiStates.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/UiStates.kt @@ -92,7 +92,8 @@ sealed class SendState { val amountSats : Long, val feeSats : Long? = null, val paymentHash: String, - val pendingRecord: PaymentRecord? = null + val pendingRecord: PaymentRecord? = null, + val lnurl : String? = null ) : SendState() data class Error(val message: String) : SendState() } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/common/PaymentSuccessContent.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/common/PaymentSuccessContent.kt new file mode 100644 index 0000000..211092b --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/common/PaymentSuccessContent.kt @@ -0,0 +1,119 @@ +package com.bitcointxoko.gudariwallet.ui.common + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.bitcointxoko.gudariwallet.LocalAppStrings +import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType +import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet +import com.bitcointxoko.gudariwallet.ui.receive.AmountDisplay + +/** + * Shared success screen used by both Send (PaymentSent) and Receive (PaymentReceived). + * + * @param title Headline string, e.g. strings.paymentSent / strings.receive.paymentReceived + * @param amountSats Amount in sats. + * @param fiatLabel Optional fiat conversion string (from [fiatLabel] util). + * @param subLine Optional secondary line below amount: fee text (send) or memo (receive). + * @param detailsEnabled Whether the Details button is clickable. + * @param onDetails Navigate to payment detail. + * @param secondaryLabel Label of the reset TextButton ("Send Another" / "Done"). + * @param onSecondary Reset screen state. + * @param lnurl Pre-fill address for the save-contact sheet. Pass null to hide the button. + * @param onSaveContact Forward to vm.saveContactFromSend — omit (null) to hide save-contact UI. + */ +@Composable +fun PaymentSuccessContent( + title : String, + amountSats : Long, + fiatLabel : String?, + subLine : String? = null, + detailsEnabled : Boolean = true, + onDetails : () -> Unit, + secondaryLabel : String, + onSecondary : () -> Unit, + lnurl : String? = null, + onSaveContact : ((name: String, addressType: PaymentAddressType?, address: String?) -> Unit)? = null, +) { + var showSaveSheet by remember { mutableStateOf(false) } + + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(72.dp) + ) + Spacer(Modifier.height(16.dp)) + Text(text = title, style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(8.dp)) + AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel) + if (!subLine.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = subLine, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // ── Save contact affordance (send-side only) ───────────────────────── + if (lnurl != null && onSaveContact != null) { + Spacer(Modifier.height(16.dp)) + OutlinedButton( + onClick = { showSaveSheet = true }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Filled.PersonAdd, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text("Save contact") + } + } + + Spacer(Modifier.height(32.dp)) + Button( + onClick = onDetails, + enabled = detailsEnabled, + modifier = Modifier.fillMaxWidth() + ) { Text(LocalAppStrings.current.details) } + Spacer(Modifier.height(32.dp)) + TextButton( + onClick = onSecondary, + modifier = Modifier.fillMaxWidth() + ) { Text(secondaryLabel) } + } + + // ── Bottom sheet ───────────────────────────────────────────────────────── + if (showSaveSheet && lnurl != null && onSaveContact != null) { + // Detect whether the string is a Lightning Address (user@domain) or LNURL + val isLnAddress = lnurl.contains('@') && !lnurl.startsWith("lnurl", ignoreCase = true) + val initialType = if (isLnAddress) + PaymentAddressType.LIGHTNING_ADDRESS + else + PaymentAddressType.LNURL + + CreateContactSheet( + initialAddress = lnurl, + initialAddressType = initialType, + onSave = { name, addressType, address -> + onSaveContact(name, addressType, address) + showSaveSheet = false + }, + onDismiss = { showSaveSheet = false } + ) + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt index 4be67cc..6f18c32 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt @@ -57,6 +57,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.ui.ReceiveState import com.bitcointxoko.gudariwallet.ui.WalletViewModel +import com.bitcointxoko.gudariwallet.ui.common.PaymentSuccessContent import com.bitcointxoko.gudariwallet.ui.common.QrDisplayCard import com.bitcointxoko.gudariwallet.ui.common.rememberBrightnessController import com.bitcointxoko.gudariwallet.ui.common.UnitWheelPicker @@ -156,16 +157,21 @@ fun ReceiveScreen( } is ReceiveState.PaymentReceived -> { - ReceiveSuccessContent( - amountSats = state.amountSats, - memo = state.memo, - fiatRate = fiatRate, - fiatCurrency = fiatCurrency, - onDone = { vm.resetReceiveState() }, - onDetails = { onNavigateToPaymentDetail(state.checkingId) } + PaymentSuccessContent( + title = strings.receive.paymentReceived, + amountSats = state.amountSats, + fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency), + subLine = state.memo?.takeIf { it.isNotBlank() }, + onDetails = { onNavigateToPaymentDetail(state.checkingId) }, + secondaryLabel = strings.done, + onSecondary = { vm.resetReceiveState() }, + lnurl = null, // sender identity not available + onSaveContact = null, ) } + + is ReceiveState.Error -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column( @@ -722,51 +728,4 @@ private fun ReceiveInvoiceContent( ) } } -} - -// ── ReceiveSuccessContent ───────────────────────────────────────────────────── - -@Composable -private fun ReceiveSuccessContent( - amountSats : Long, - memo : String?, - fiatRate : Double?, - fiatCurrency : String?, - onDone : () -> Unit, - onDetails : () -> Unit -) { - val strings = LocalAppStrings.current - val fiatLabel = fiatLabel(amountSats, fiatRate, fiatCurrency) - - Column( - modifier = Modifier.fillMaxSize().padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(72.dp) - ) - Spacer(Modifier.height(16.dp)) - Text( - text = strings.receive.paymentReceived, // ← "Payment Received!" - style = MaterialTheme.typography.headlineSmall - ) - Spacer(Modifier.height(8.dp)) - AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel) - if (!memo.isNullOrBlank()) { - Spacer(Modifier.height(4.dp)) - Text(memo, style = MaterialTheme.typography.bodyMedium) - } - Spacer(Modifier.height(32.dp)) - Button(onClick = onDetails, modifier = Modifier.fillMaxWidth()) { - Text(strings.details) // ← "Details" - } - Spacer(Modifier.height(32.dp)) - TextButton(onClick = onDone, modifier = Modifier.fillMaxWidth()) { - Text(strings.done) // ← "Done" - } - } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendScreen.kt index 525da0c..4eab373 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendScreen.kt @@ -28,6 +28,7 @@ import com.bitcointxoko.gudariwallet.ui.BalanceState import com.bitcointxoko.gudariwallet.ui.NfcOfferState import com.bitcointxoko.gudariwallet.ui.SendState import com.bitcointxoko.gudariwallet.ui.WalletViewModel +import com.bitcointxoko.gudariwallet.ui.common.PaymentSuccessContent import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel import com.bitcointxoko.gudariwallet.ui.receive.AmountDisplay import com.bitcointxoko.gudariwallet.util.SendInputDetector @@ -286,56 +287,31 @@ fun SendScreen( } else null val feeText = when { state.feeSats == null || state.feeSats == 0L -> - strings.noFees // ← "No fees" + strings.noFees else -> { val ppmSuffix = if (ppm != null) " (${ppm} ppm)" else "" - strings.routingFee(state.feeSats, ppmSuffix) // ← "Routing fee: X sats (Y ppm)" + strings.routingFee(state.feeSats, ppmSuffix) } } - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(72.dp) - ) - Spacer(Modifier.height(16.dp)) - Text( - text = strings.paymentSent, // ← "Payment sent!" - style = MaterialTheme.typography.headlineSmall - ) - Spacer(Modifier.height(8.dp)) - AmountDisplay(amountSats = state.amountSats, fiatLabel = fiatLabel) - Spacer(Modifier.height(4.dp)) - Text( - text = feeText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(32.dp)) - Button( - onClick = { - val record = state.pendingRecord - if (record != null) { - onViewDetails(state.paymentHash, record) - } - }, - enabled = state.pendingRecord != null, - modifier = Modifier.fillMaxWidth() - ) { Text(strings.details) } // ← "Details" - Spacer(Modifier.height(32.dp)) - TextButton( - onClick = { vm.resetSendState(); input = "" }, - modifier = Modifier.fillMaxWidth() - ) { Text(strings.sendAnother) } // ← "Send Another" - } + PaymentSuccessContent( + title = strings.paymentSent, + amountSats = state.amountSats, + fiatLabel = fiatLabel, + subLine = feeText, + detailsEnabled = state.pendingRecord != null, + onDetails = { + val record = state.pendingRecord + if (record != null) onViewDetails(state.paymentHash, record) + }, + secondaryLabel = strings.sendAnother, + onSecondary = { vm.resetSendState(); input = "" }, + lnurl = state.lnurl, // null for plain Bolt11 + onSaveContact = vm::saveContactFromSend + ) } + is SendState.Error -> { Spacer(Modifier.height(8.dp)) Text( diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt index a2acba7..795e607 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt @@ -134,20 +134,21 @@ class SendViewModel( pubkey : String?, alias : String?, strings : SendStrings, + lnurl : String? = null ) { val detail = runCatching { repo.getPaymentDetail(paymentHash) }.getOrNull() when { // ── Settled immediately detail?.paid == true -> { - emitPaymentSent(paymentHash, amountSats, detail.details?.feeSat, bolt11, memo, pubkey, alias) + emitPaymentSent(paymentHash, amountSats, detail.details?.feeSat, bolt11, memo, pubkey, alias, lnurl) } // ── Hold invoice — poll detail?.details?.status == "pending" || detail?.details?.pending == true -> { when (val result = poller.poll(paymentHash)) { is PaymentPoller.PollResult.Settled -> - emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias) + emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias, lnurl) is PaymentPoller.PollResult.Failed -> _sendState.value = SendState.Error(strings.paymentFailed) @@ -162,7 +163,7 @@ class SendViewModel( Timber.w("SEND [DETAIL NULL] polling as fallback") when (val result = poller.poll(paymentHash)) { is PaymentPoller.PollResult.Settled -> - emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias) + emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias, lnurl) else -> _sendState.value = SendState.Error(strings.paymentFailed) } @@ -183,12 +184,14 @@ class SendViewModel( memo : String?, pubkey : String?, alias : String?, + lnurl : String? = null ) { _sendState.value = SendState.PaymentSent( amountSats = amountSats, feeSats = feeSats, paymentHash = paymentHash, - pendingRecord = PaymentRecord.fromPayment(paymentHash, amountSats, feeSats, bolt11, memo, pubkey, alias) + pendingRecord = PaymentRecord.fromPayment(paymentHash, amountSats, feeSats, bolt11, memo, pubkey, alias), + lnurl = lnurl ) _events.tryEmit(SendEvent.PaymentCompleted) } @@ -224,6 +227,7 @@ class SendViewModel( pubkey = null, alias = null, strings = strings, + lnurl = lnurl ) } else { _sendState.value = SendState.Error( @@ -323,6 +327,7 @@ class SendViewModel( pubkey = state.payee, alias = state.nodeAlias, strings = strings, + lnurl = state.lnurl ) return@launch }