Compare commits

2 Commits

Author SHA1 Message Date
rasputin 2e11cecbeb refactor: extract PaymentSuccessContent 2026-06-13 17:50:17 +02:00
rasputin e57414de17 feat: send from contact details 2026-06-13 16:09:58 +02:00
8 changed files with 210 additions and 107 deletions
@@ -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()
}
@@ -537,19 +537,30 @@ fun WalletScreen(
composable(
route = ROUTE_CONTACT_DETAIL,
arguments = listOf(navArgument("contactId") { type = NavType.StringType }),
enterTransition = { fadeIn(animationSpec = tween(150)) },
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) { backStackEntry ->
val contactId = backStackEntry.arguments?.getString("contactId") ?: ""
val detailVm: ContactDetailViewModel = viewModel(
key = contactId,
key = contactId,
factory = ContactDetailViewModel.Factory(contactId, vm.contactRepo)
)
ContactDetailScreen(
viewModel = detailVm,
onBack = { navController.popBackStack() }
onBack = { navController.popBackStack() },
onPayAddress = { address ->
vm.prefillSend(address)
navController.navigate(TabItem.Send.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = false // don't restore stale SendScreen state
}
}
)
}
}
// ── Clipboard banner — floats over content, slides in from top ────
@@ -23,6 +23,7 @@ import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
import com.bitcointxoko.gudariwallet.ui.send.SendEvent
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@@ -116,6 +117,16 @@ class WalletViewModel(
val sendEvents: SharedFlow<SendEvent> = sendVm.events
// ── Contact tap-to-pay prefill ────────────────────────────────────────────
private val _pendingSend = MutableStateFlow<String?>(null)
val pendingSend: StateFlow<String?> get() = _pendingSend
/** Called by ContactDetailScreen before navigating to the Send tab. */
fun prefillSend(address: String) { _pendingSend.value = address }
/** Called by SendScreen once it has consumed the prefilled address. */
fun consumePendingSend() { _pendingSend.value = null }
fun handleWithdrawScanned(raw: LnurlScanResponse, lnurl: String) {
receiveVm.handleWithdrawResponse(raw, lnurl)
}
@@ -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 }
)
}
}
@@ -1,6 +1,7 @@
package com.bitcointxoko.gudariwallet.ui.contacts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -37,7 +38,8 @@ import com.bitcointxoko.gudariwallet.ui.common.DetailSection
@Composable
fun ContactDetailScreen(
viewModel: ContactDetailViewModel,
onBack: () -> Unit
onBack: () -> Unit,
onPayAddress: (address: String) -> Unit = {}
) {
val strings = LocalAppStrings.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -127,6 +129,7 @@ fun ContactDetailScreen(
isEditing = isEditing,
onRemoveAddress = { addressId -> pendingAddressDelete = addressId },
onSetDefault = { viewModel.setDefaultAddress(it) },
onPayAddress = onPayAddress,
onEditAlias = { viewModel.openEditAliasSheet() },
onDeleteContact = { viewModel.requestDelete() },
modifier = Modifier.padding(innerPadding)
@@ -209,6 +212,7 @@ private fun ContactDetailContent(
isEditing: Boolean,
onRemoveAddress: (addressId: String) -> Unit,
onSetDefault: (PaymentAddressEntity) -> Unit,
onPayAddress: (address: String) -> Unit,
onEditAlias: () -> Unit,
onDeleteContact: () -> Unit,
modifier: Modifier = Modifier
@@ -276,6 +280,7 @@ private fun ContactDetailContent(
AddressRow(
address = addr,
isEditing = isEditing,
onPay = { onPayAddress(addr.address) },
onRemove = { onRemoveAddress(addr.id) },
onSetDefault = { onSetDefault(addr) }
)
@@ -349,6 +354,7 @@ private fun ContactDetailContent(
private fun AddressRow(
address: PaymentAddressEntity,
isEditing: Boolean,
onPay: () -> Unit,
onRemove: () -> Unit,
onSetDefault: () -> Unit,
) {
@@ -365,6 +371,10 @@ private fun AddressRow(
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
enabled = !isEditing,
onClickLabel = "Pay with this address"
) { onPay() }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -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(
@@ -723,50 +729,3 @@ 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.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
@@ -59,6 +60,17 @@ fun SendScreen(
}
val fiatCurrency = (balanceState as? BalanceState.Success)?.fiatCurrency
// ── Consume address prefilled from Contacts tap-to-pay ───────────────────
val pendingSend by vm.pendingSend.collectAsStateWithLifecycle()
LaunchedEffect(pendingSend) {
val address = pendingSend
if (!address.isNullOrBlank() && sendState is SendState.Idle) {
input = SendInputDetector.normalize(address)
vm.scan(address, sendStrings)
vm.consumePendingSend()
}
}
// ── Collect NFC offer at top level — never inside a conditional ───────────
val nfcOffer by nfcVm.nfcOfferState.collectAsStateWithLifecycle()
@@ -275,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(
@@ -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
}