mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
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:
@@ -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.
|
||||
|
||||
+215
-27
@@ -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)) }
|
||||
}
|
||||
|
||||
+29
-2
@@ -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 --------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user