ui(cashu): wallet polish — discard invoice, tile alignment, history rows

Three small UI fixes on the Cashu wallet screen:

- Add a Discard button to the Receive dialog. When the user requested an
  invoice they no longer want (typo'd amount, wrong mint, changed their
  mind), tap Discard to NIP-09-delete the kind:7374 mint quote so the
  pending-quote banner stops re-surfacing it.

- Stop the action-row labels from clipping. The previous
  OutlinedButton-per-tile layout, with 4 tiles in a row plus default
  24dp horizontal padding and a 20dp icon, clipped "Send Token" on
  standard 360dp phones. Replaced with a custom Surface+Column tile that
  gives us control over padding and lets the label wrap to 2 lines.

- Render history rows like the LN + on-chain transaction lists:
  counterparty avatar on the left, name + timestamp in the middle,
  signed amount on the right. For inbound nutzap redemptions we resolve
  the sender's pubkey from the redeemed-marker `e` tag (the kind:9321
  is in LocalCache thanks to the cashu filter assembler). For
  everything else there's no Nostr counterparty, so we fall back to a
  directional arrow icon matching the LN wallet's empty-counterparty
  pattern.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
Claude
2026-05-27 15:17:42 +00:00
parent 9cfca5272e
commit e4b0d80e5c
4 changed files with 265 additions and 29 deletions
@@ -156,6 +156,24 @@ class CashuWalletOps(
quoteId: String,
): MintQuoteBolt11ResponseDto = ops(mintUrl).mintQuoteStatus(quoteId)
/**
* User-initiated abandonment of a pending mint quote.
*
* NIP-09 deletes the kind:7374 so the quote no longer shows in the
* pending banner. We do NOT call the mint to cancel — bolt11 invoices
* just expire on their own, and a fresh quote is cheap if the user
* changes their mind.
*
* No-op if the invoice was already paid: by the time the mint issues
* proofs and completeMintFromLightning runs, the same NIP-09 delete is
* fired anyway; running it twice on a missing event is harmless.
*/
suspend fun cancelMintQuote(quoteEvent: CashuMintQuoteEvent) {
val delTemplate = DeletionEvent.build(listOf(quoteEvent))
val delEvent = signer.sign(delTemplate)
publish(delEvent)
}
/**
* Mint phase 2: after the user paid the invoice, exchange the quote for
* proofs, publish them as kind:7375, log kind:7376, and delete kind:7374.
@@ -49,6 +49,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -57,14 +58,18 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -72,13 +77,20 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.components.util.getText
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.history.SpendingDirection
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.text.DateFormat
@@ -145,6 +157,8 @@ fun CashuWalletScreen(
mints = mints,
history = history,
pendingQuoteCount = pendingQuotes.size,
accountViewModel = accountViewModel,
nav = nav,
onReceive = { receiveOpen = true },
onSendLn = { sendLnOpen = true },
onSendToken = { sendTokenOpen = true },
@@ -237,7 +251,7 @@ private fun DiscoveringCashuWallet(modifier: Modifier) {
text = stringRes(R.string.cashu_discovering_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
textAlign = TextAlign.Center,
)
}
}
@@ -280,6 +294,8 @@ private fun CashuWalletContent(
mints: List<String>,
history: List<CashuSpendingHistoryEvent>,
pendingQuoteCount: Int,
accountViewModel: AccountViewModel,
nav: INav,
onReceive: () -> Unit,
onSendLn: () -> Unit,
onSendToken: () -> Unit,
@@ -334,7 +350,7 @@ private fun CashuWalletContent(
)
}
items(history, key = { it.id }) { entry ->
HistoryRow(entry)
HistoryRow(entry, accountViewModel, nav)
}
}
@@ -377,12 +393,11 @@ private fun PendingQuoteBanner(
Column(modifier = Modifier.weight(1f)) {
Text(
text =
androidx.compose.ui.res
.pluralStringResource(
R.plurals.cashu_pending_quotes_title,
count,
count,
),
pluralStringResource(
R.plurals.cashu_pending_quotes_title,
count,
count,
),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSecondaryContainer,
@@ -450,29 +465,57 @@ private fun ActionRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ActionButton(MaterialSymbols.Bolt, stringRes(R.string.cashu_action_receive), onReceive, Modifier.weight(1f))
ActionButton(MaterialSymbols.AutoMirrored.Send, stringRes(R.string.cashu_action_send_ln), onSendLn, Modifier.weight(1f))
ActionButton(MaterialSymbols.ContentPaste, stringRes(R.string.cashu_action_send_token), onSendToken, Modifier.weight(1f))
ActionButton(MaterialSymbols.Add, stringRes(R.string.cashu_action_redeem), onRedeem, Modifier.weight(1f))
ActionTile(MaterialSymbols.Bolt, stringRes(R.string.cashu_action_receive), onReceive, Modifier.weight(1f))
ActionTile(MaterialSymbols.AutoMirrored.Send, stringRes(R.string.cashu_action_send_ln), onSendLn, Modifier.weight(1f))
ActionTile(MaterialSymbols.ContentPaste, stringRes(R.string.cashu_action_send_token), onSendToken, Modifier.weight(1f))
ActionTile(MaterialSymbols.Add, stringRes(R.string.cashu_action_redeem), onRedeem, Modifier.weight(1f))
}
}
/**
* Square-ish tile with the icon stacked over a 1-2 line label.
*
* Replaces an earlier OutlinedButton-per-tile layout: with 4 tiles in a row
* and OutlinedButton's default 24dp horizontal padding plus a 20dp icon,
* the "Send Token" label clipped on standard 360dp-wide phones. A Surface
* with explicit padding gives us full control over the content area.
*/
@Composable
private fun ActionButton(
private fun ActionTile(
icon: MaterialSymbol,
label: String,
onClick: () -> Unit,
modifier: Modifier,
) {
OutlinedButton(
onClick = onClick,
modifier = modifier,
Surface(
modifier = modifier.height(72.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(symbol = icon, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.height(2.dp))
Text(label, style = MaterialTheme.typography.labelSmall, maxLines = 1)
Column(
modifier =
Modifier
.clickable(onClick = onClick)
.fillMaxSize()
.padding(horizontal = 4.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
symbol = icon,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -512,8 +555,49 @@ private fun MintRow(
}
}
/**
* Renders one kind:7376 history event in the same visual language as the
* LN + on-chain transaction lists: counterparty avatar on the left,
* description on top, timestamp underneath, signed amount on the right.
*
* For inbound nutzap redemptions (kind:9321 referenced with a
* "redeemed" marker on the kind:7376) we can resolve the sender's
* Nostr pubkey from LocalCache and show their profile picture + name.
* For everything else (LN mint/melt, send-as-token, redeem cashuB)
* there is no Nostr counterparty, so we fall back to a directional
* arrow icon matching the LN wallet's empty-counterparty pattern.
*/
@Composable
private fun HistoryRow(entry: CashuSpendingHistoryEvent) {
private fun HistoryRow(
entry: CashuSpendingHistoryEvent,
accountViewModel: AccountViewModel,
nav: INav,
) {
val signer = accountViewModel.account.signer
// Decrypt the encrypted half of the spending history once per row.
// Cheap (NIP-44 on a few hundred bytes) and Compose memoizes the
// produceState by entry.id so it doesn't repeat on recomposition.
val decoded by produceState<DecodedHistoryRow?>(initialValue = null, key1 = entry.id) {
value = decodeHistoryRow(entry, signer)
}
val nutzapSenderHex =
remember(decoded) {
// Sender lookup: the redeemed-marker `e` tag points at the kind:9321
// event id; if it's already in LocalCache (we get it via our cashu
// wallet filter assembler) its event.pubKey is the sender.
entry.redeemedReferences().firstNotNullOfOrNull { ref ->
LocalCache
.getOrCreateNote(ref.eventId)
.event
?.takeIf { it is NutzapEvent }
?.pubKey
}
}
val isIncoming = decoded?.direction == SpendingDirection.IN
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
@@ -522,21 +606,114 @@ private fun HistoryRow(entry: CashuSpendingHistoryEvent) {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.id.take(12) + "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
// Left avatar / icon.
if (nutzapSenderHex != null) {
UserPicture(
userHex = nutzapSenderHex,
size = 40.dp,
accountViewModel = accountViewModel,
nav = nav,
)
} else {
Icon(
symbol = if (isIncoming) MaterialSymbols.ArrowDownward else MaterialSymbols.ArrowUpward,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint =
if (isIncoming) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
Spacer(modifier = Modifier.width(12.dp))
// Middle: title + timestamp.
Column(modifier = Modifier.weight(1f)) {
if (nutzapSenderHex != null) {
HistoryUserName(nutzapSenderHex, accountViewModel)
} else {
Text(
text =
stringRes(
if (isIncoming) {
R.string.cashu_history_incoming
} else {
R.string.cashu_history_outgoing
},
),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text =
DateFormat
.getDateTimeInstance()
.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
.format(Date(entry.createdAt * 1000)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Right: signed amount.
val amount = decoded?.amount
if (amount != null) {
Text(
text = (if (isIncoming) "+" else "") + NumberFormat.getIntegerInstance().format(amount),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color =
if (isIncoming) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
private data class DecodedHistoryRow(
val direction: SpendingDirection?,
val amount: Long?,
)
private suspend fun decodeHistoryRow(
entry: CashuSpendingHistoryEvent,
signer: NostrSigner,
): DecodedHistoryRow =
runCatching {
DecodedHistoryRow(
direction = entry.direction(signer),
amount = entry.amount(signer),
)
}.getOrNull() ?: DecodedHistoryRow(direction = null, amount = null)
@Composable
private fun HistoryUserName(
pubkeyHex: String,
accountViewModel: AccountViewModel,
) {
LoadUser(baseUserHex = pubkeyHex, accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(
baseUser = user,
fontWeight = FontWeight.Medium,
accountViewModel = accountViewModel,
)
} else {
Text(
text = pubkeyHex.take(8) + "",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -671,6 +848,17 @@ private fun ReceiveDialog(
) { Text(stringRes(R.string.cashu_request_invoice)) }
}
is CashuMintFlowState.AwaitingPayment -> {
// While the user is waiting for an invoice they may
// decide they don't want it after all (typo'd amount,
// wrong mint, lost interest). Discard NIP-09-deletes
// the kind:7374 so the pending-invoice banner won't
// re-surface it on next entry.
TextButton(onClick = { viewModel.discardMintQuote() }) {
Text(stringRes(R.string.cashu_discard_invoice))
}
}
is CashuMintFlowState.Completed -> {
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cashu_done)) }
}
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -219,8 +220,7 @@ class CashuWalletViewModel : ViewModel() {
ops.recommendMint(mintUrl = mintUrl, mintAnnouncementDTag = dTag, review = review)
} catch (e: Exception) {
// Best-effort — the UI doesn't need to block on the result.
com.vitorpamplona.quartz.utils.Log
.w("CashuWallet") { "recommendMint($mintUrl) failed: ${describeMintError(e)}" }
Log.w("CashuWallet") { "recommendMint($mintUrl) failed: ${describeMintError(e)}" }
}
}
}
@@ -368,6 +368,33 @@ class CashuWalletViewModel : ViewModel() {
_mintState.value = CashuMintFlowState.Idle
}
/**
* User-initiated discard of the pending mint quote currently showing in
* the Receive dialog. NIP-09 deletes the kind:7374 so the pending-quote
* banner doesn't keep re-surfacing it, then drops back to Idle.
*
* Works from both the AwaitingPayment state (just-requested invoice)
* and the Error state (failed completion). No-op otherwise.
*/
fun discardMintQuote() {
val vm = accountViewModel ?: return
val quoteEvent =
when (val s = _mintState.value) {
is CashuMintFlowState.AwaitingPayment -> s.flow.quoteEvent
else -> null
} ?: run {
_mintState.value = CashuMintFlowState.Idle
return
}
vm.launchSigner {
runCatching { ops.cancelMintQuote(quoteEvent) }
.onFailure {
Log.w("CashuWallet") { "cancelMintQuote failed: ${describeMintError(it)}" }
}
_mintState.value = CashuMintFlowState.Idle
}
}
// -------- Melt to LN --------
/**
+3
View File
@@ -1915,6 +1915,9 @@
<string name="cashu_token_label">Cashu token</string>
<string name="cashu_copy_invoice">Copy invoice</string>
<string name="cashu_request_invoice">Request invoice</string>
<string name="cashu_discard_invoice">Discard invoice</string>
<string name="cashu_history_incoming">Received</string>
<string name="cashu_history_outgoing">Sent</string>
<string name="cashu_pay_invoice">Pay invoice</string>
<string name="cashu_get_quote">Get quote</string>
<string name="cashu_getting_quote">Asking mint for a quote…</string>