refactor: extract PaymentSuccessContent

This commit is contained in:
2026-06-13 17:50:17 +02:00
parent e57414de17
commit 2e11cecbeb
5 changed files with 163 additions and 103 deletions
@@ -92,7 +92,8 @@ sealed class SendState {
val amountSats : Long, val amountSats : Long,
val feeSats : Long? = null, val feeSats : Long? = null,
val paymentHash: String, val paymentHash: String,
val pendingRecord: PaymentRecord? = null val pendingRecord: PaymentRecord? = null,
val lnurl : String? = null
) : SendState() ) : SendState()
data class Error(val message: String) : SendState() data class Error(val message: String) : SendState()
} }
@@ -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 }
)
}
}
@@ -57,6 +57,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.ReceiveState import com.bitcointxoko.gudariwallet.ui.ReceiveState
import com.bitcointxoko.gudariwallet.ui.WalletViewModel 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.QrDisplayCard
import com.bitcointxoko.gudariwallet.ui.common.rememberBrightnessController import com.bitcointxoko.gudariwallet.ui.common.rememberBrightnessController
import com.bitcointxoko.gudariwallet.ui.common.UnitWheelPicker import com.bitcointxoko.gudariwallet.ui.common.UnitWheelPicker
@@ -156,16 +157,21 @@ fun ReceiveScreen(
} }
is ReceiveState.PaymentReceived -> { is ReceiveState.PaymentReceived -> {
ReceiveSuccessContent( PaymentSuccessContent(
amountSats = state.amountSats, title = strings.receive.paymentReceived,
memo = state.memo, amountSats = state.amountSats,
fiatRate = fiatRate, fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency),
fiatCurrency = fiatCurrency, subLine = state.memo?.takeIf { it.isNotBlank() },
onDone = { vm.resetReceiveState() }, onDetails = { onNavigateToPaymentDetail(state.checkingId) },
onDetails = { onNavigateToPaymentDetail(state.checkingId) } secondaryLabel = strings.done,
onSecondary = { vm.resetReceiveState() },
lnurl = null, // sender identity not available
onSaveContact = null,
) )
} }
is ReceiveState.Error -> { is ReceiveState.Error -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column( 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"
}
}
}
@@ -28,6 +28,7 @@ import com.bitcointxoko.gudariwallet.ui.BalanceState
import com.bitcointxoko.gudariwallet.ui.NfcOfferState import com.bitcointxoko.gudariwallet.ui.NfcOfferState
import com.bitcointxoko.gudariwallet.ui.SendState import com.bitcointxoko.gudariwallet.ui.SendState
import com.bitcointxoko.gudariwallet.ui.WalletViewModel 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.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.ui.receive.AmountDisplay import com.bitcointxoko.gudariwallet.ui.receive.AmountDisplay
import com.bitcointxoko.gudariwallet.util.SendInputDetector import com.bitcointxoko.gudariwallet.util.SendInputDetector
@@ -286,56 +287,31 @@ fun SendScreen(
} else null } else null
val feeText = when { val feeText = when {
state.feeSats == null || state.feeSats == 0L -> state.feeSats == null || state.feeSats == 0L ->
strings.noFees // ← "No fees" strings.noFees
else -> { else -> {
val ppmSuffix = if (ppm != null) " (${ppm} ppm)" 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( PaymentSuccessContent(
modifier = Modifier.fillMaxSize(), title = strings.paymentSent,
horizontalAlignment = Alignment.CenterHorizontally, amountSats = state.amountSats,
verticalArrangement = Arrangement.Center fiatLabel = fiatLabel,
) { subLine = feeText,
Icon( detailsEnabled = state.pendingRecord != null,
imageVector = Icons.Filled.CheckCircle, onDetails = {
contentDescription = null, val record = state.pendingRecord
tint = MaterialTheme.colorScheme.primary, if (record != null) onViewDetails(state.paymentHash, record)
modifier = Modifier.size(72.dp) },
) secondaryLabel = strings.sendAnother,
Spacer(Modifier.height(16.dp)) onSecondary = { vm.resetSendState(); input = "" },
Text( lnurl = state.lnurl, // null for plain Bolt11
text = strings.paymentSent, // ← "Payment sent!" onSaveContact = vm::saveContactFromSend
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"
}
} }
is SendState.Error -> { is SendState.Error -> {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
@@ -134,20 +134,21 @@ class SendViewModel(
pubkey : String?, pubkey : String?,
alias : String?, alias : String?,
strings : SendStrings, strings : SendStrings,
lnurl : String? = null
) { ) {
val detail = runCatching { repo.getPaymentDetail(paymentHash) }.getOrNull() val detail = runCatching { repo.getPaymentDetail(paymentHash) }.getOrNull()
when { when {
// ── Settled immediately // ── Settled immediately
detail?.paid == true -> { 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 // ── Hold invoice — poll
detail?.details?.status == "pending" || detail?.details?.pending == true -> { detail?.details?.status == "pending" || detail?.details?.pending == true -> {
when (val result = poller.poll(paymentHash)) { when (val result = poller.poll(paymentHash)) {
is PaymentPoller.PollResult.Settled -> 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 -> is PaymentPoller.PollResult.Failed ->
_sendState.value = SendState.Error(strings.paymentFailed) _sendState.value = SendState.Error(strings.paymentFailed)
@@ -162,7 +163,7 @@ class SendViewModel(
Timber.w("SEND [DETAIL NULL] polling as fallback") Timber.w("SEND [DETAIL NULL] polling as fallback")
when (val result = poller.poll(paymentHash)) { when (val result = poller.poll(paymentHash)) {
is PaymentPoller.PollResult.Settled -> is PaymentPoller.PollResult.Settled ->
emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias) emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias, lnurl)
else -> else ->
_sendState.value = SendState.Error(strings.paymentFailed) _sendState.value = SendState.Error(strings.paymentFailed)
} }
@@ -183,12 +184,14 @@ class SendViewModel(
memo : String?, memo : String?,
pubkey : String?, pubkey : String?,
alias : String?, alias : String?,
lnurl : String? = null
) { ) {
_sendState.value = SendState.PaymentSent( _sendState.value = SendState.PaymentSent(
amountSats = amountSats, amountSats = amountSats,
feeSats = feeSats, feeSats = feeSats,
paymentHash = paymentHash, 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) _events.tryEmit(SendEvent.PaymentCompleted)
} }
@@ -224,6 +227,7 @@ class SendViewModel(
pubkey = null, pubkey = null,
alias = null, alias = null,
strings = strings, strings = strings,
lnurl = lnurl
) )
} else { } else {
_sendState.value = SendState.Error( _sendState.value = SendState.Error(
@@ -323,6 +327,7 @@ class SendViewModel(
pubkey = state.payee, pubkey = state.payee,
alias = state.nodeAlias, alias = state.nodeAlias,
strings = strings, strings = strings,
lnurl = state.lnurl
) )
return@launch return@launch
} }