fix(cashu): replace auto-popup with a tappable pending-quote banner

Bug: every entry to the wallet screen re-opened the Receive dialog if
any pending kind:7374 mint quote existed. So a user who got an invoice
and then navigated away — for any reason, even without dismissing —
would be greeted by the same invoice dialog the next time they opened
the wallet. Worse: even after they'd paid and the mint had issued
proofs, the brief window before the kind:7374 NIP-09 delete propagated
would cause the dialog to pop again.

Fix: drop the LaunchedEffect(pendingQuotes) auto-resume; replace with
a non-modal banner card just under the BalanceCard that shows
"N pending invoices · Tap to resume". The user opts in by tapping it.

The underlying CashuWalletState.pendingQuotes flow + the
viewModel.resumeMintQuote() VM method are unchanged — only the
trigger surface moves from "auto" to "user-initiated".

playDebug + fdroidDebug compile clean; 24/24 jvm tests still pass.
This commit is contained in:
Claude
2026-05-27 15:17:42 +00:00
parent 8ac2a6cac3
commit 817227806b
2 changed files with 87 additions and 9 deletions
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -107,15 +108,11 @@ fun CashuWalletScreen(
var sendTokenOpen by remember { mutableStateOf(false) }
var redeemOpen by remember { mutableStateOf(false) }
// If the user has an unfinished kind:7374 quote, surface it the next time
// they open the wallet so the in-flight invoice isn't lost. Auto-resumes
// the most recent quote on first composition.
LaunchedEffect(pendingQuotes) {
if (!receiveOpen && pendingQuotes.isNotEmpty()) {
viewModel.resumeMintQuote(pendingQuotes.first())
receiveOpen = true
}
}
// pendingQuotes drives a non-modal banner in the wallet body (see
// PendingQuoteBanner below). Tapping the banner is what opens the
// Receive dialog with the stored quote pre-loaded — we do NOT auto-pop
// the dialog on every entry to the screen, which would re-surface
// dismissed quotes every time the user navigates back.
Scaffold(
topBar = {
@@ -147,11 +144,18 @@ fun CashuWalletScreen(
balanceSats = balanceSats,
mints = mints,
history = history,
pendingQuoteCount = pendingQuotes.size,
onReceive = { receiveOpen = true },
onSendLn = { sendLnOpen = true },
onSendToken = { sendTokenOpen = true },
onRedeem = { redeemOpen = true },
onRecommendMint = { viewModel.recommendMint(it) },
onResumePendingQuote = {
pendingQuotes.firstOrNull()?.let {
viewModel.resumeMintQuote(it)
receiveOpen = true
}
},
)
// NIP-60 wallets are portable across clients — show a "looking
@@ -275,11 +279,13 @@ private fun CashuWalletContent(
balanceSats: Long,
mints: List<String>,
history: List<CashuSpendingHistoryEvent>,
pendingQuoteCount: Int,
onReceive: () -> Unit,
onSendLn: () -> Unit,
onSendToken: () -> Unit,
onRedeem: () -> Unit,
onRecommendMint: (String) -> Unit,
onResumePendingQuote: () -> Unit,
) {
LazyColumn(
modifier =
@@ -293,6 +299,10 @@ private fun CashuWalletContent(
BalanceCard(balanceSats)
}
if (pendingQuoteCount > 0) {
item { PendingQuoteBanner(count = pendingQuoteCount, onResume = onResumePendingQuote) }
}
item {
ActionRow(
onReceive = onReceive,
@@ -332,6 +342,67 @@ private fun CashuWalletContent(
}
}
/**
* Banner that surfaces unfinished mint quotes — tappable to resume the
* receive flow with the stored invoice. Driven by
* [com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState.pendingQuotes].
*
* Replaces the earlier auto-popup behaviour which re-surfaced the Receive
* dialog on every entry to the screen.
*/
@Composable
private fun PendingQuoteBanner(
count: Int,
onResume: () -> Unit,
) {
Card(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onResume),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text =
androidx.compose.ui.res
.pluralStringResource(
R.plurals.cashu_pending_quotes_title,
count,
count,
),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Text(
text = stringRes(R.string.cashu_pending_quotes_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f),
)
}
Text(
text = stringRes(R.string.cashu_pending_quotes_resume),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
@Composable
private fun BalanceCard(balanceSats: Long) {
val formatted =
+7
View File
@@ -2857,6 +2857,13 @@
<item quantity="other">%1$d mints</item>
</plurals>
<plurals name="cashu_pending_quotes_title">
<item quantity="one">%1$d pending invoice</item>
<item quantity="other">%1$d pending invoices</item>
</plurals>
<string name="cashu_pending_quotes_subtitle">Tap to resume and check status.</string>
<string name="cashu_pending_quotes_resume">Resume</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="event_sync_status_connecting">Connecting</string>
<string name="event_sync_status_downloading">Downloading</string>