refactor: extract PaymentSuccessContent
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user