From c1c1a69148484ee5144dd973bf03e39db159940f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 18:09:22 +0000 Subject: [PATCH 01/27] feat(zap): unify zap picker into one chip per amount with per-rail logos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the three parallel chip rows (cashu / lightning / on-chain) in the zap amount popup with a single pill per amount that shows a tappable logo for each rail that can actually pay it. Tapping the amount fires the cheapest/fastest available rail (cashu funded -> lightning -> on-chain); tapping a specific logo forces that rail. Rail gating: - Cashu: precise per-amount status (FUNDED / NEEDS_RELOAD / IMPOSSIBLE / UNAVAILABLE) computed from in-memory proofs — the one balance that is free to read synchronously. Only FUNDED is offered for now. - Lightning: optimistic — an external wallet can pay any invoice, so it's offered whenever the recipient can receive, with no sender-balance fetch. - On-chain: offered when a backend is configured and the amount clears the on-chain minimum; UTXO sufficiency stays a send-time check. Also fixes the original "No proofs available at " failure: nutzap mint selection now picks the shared mint where we hold the most balance instead of the first one the recipient lists (peekNutzapFunding), so a zap no longer fails when funds sit in a different shared mint. Cashu capability is now resolved against the note author (who sendNutzap actually pays) rather than split recipients. Amount presets from the (still separate) lightning and on-chain settings are merged into one sorted set for display. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../model/nip60Cashu/CashuWalletState.kt | 64 +++- .../amethyst/model/zap/RailCapability.kt | 47 ++- .../ui/components/ReusableZapButton.kt | 4 - .../amethyst/ui/note/ReactionsRow.kt | 310 +++++++++--------- .../zap/RailCapabilityCashuStatusTest.kt | 79 +++++ 5 files changed, 336 insertions(+), 168 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/zap/RailCapabilityCashuStatusTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 4e6f296435..567cb11595 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -760,22 +760,55 @@ class CashuWalletState( * - we share no mint with them, or * - their kind:10019 has no P2PK pubkey. */ - fun peekNutzapTarget(recipientPubKey: HexKey): NutzapTarget? { + fun peekNutzapTarget(recipientPubKey: HexKey): NutzapTarget? = peekNutzapFunding(recipientPubKey)?.target + + /** + * Resolve nutzap funding for [recipientPubKey]: the best shared mint to + * spend from plus the balance figures the zap picker needs to classify + * each amount as funded, reloadable, or out of reach. + * + * The chosen [NutzapFunding.target] is the shared mint where we hold the + * **most** balance — not merely the first one the recipient lists. The + * previous `firstOrNull` could pick an empty shared mint and make a zap + * fail with "No proofs available at " even though another shared + * mint was funded. + * + * All reads are synchronous from in-memory state (`_mints`, + * `_tokenEntries`, `LocalCache`), so this is safe to call from a + * composable `remember {}`. The User pins the recipient's kind:10019 + * addressable note for its own lifetime, so the chip no longer races the + * notes.LargeSoftCache eviction. + * + * Returns null when a nutzap is structurally impossible: we have no mints, + * the recipient has no kind:10019, no P2PK pubkey, or shares no mint with + * us. A non-null result still does not guarantee a single mint can cover a + * given amount — compare against [NutzapFunding.bestSingleMintSats]. + */ + fun peekNutzapFunding(recipientPubKey: HexKey): NutzapFunding? { val ourMints = _mints.value.toSet() if (ourMints.isEmpty()) return null - // Read the recipient's kind:10019 via their User — User pins the - // addressable note for its own lifetime, so the previous race - // (notes.LargeSoftCache evicts the WeakReference even though the - // event was delivered) no longer drops the chip. val info = cache.getOrCreateUser(recipientPubKey).nutzapInfo() ?: return null val recipientPubkeyHex = info.p2pkPubkey() ?: return null - val shared = info.mints().firstOrNull { it.mintUrl in ourMints } ?: return null + val sharedMints = info.mints().map { it.mintUrl }.filter { it in ourMints } + if (sharedMints.isEmpty()) return null - return NutzapTarget( - mintUrl = shared.mintUrl, - recipientP2pkPubkeyHex = recipientPubkeyHex, + val entries = _tokenEntries.value + var bestMint = sharedMints.first() + var bestMintSats = 0L + for (mint in sharedMints) { + val balance = entries.filter { it.content.mint == mint }.sumOf { it.content.totalAmount() } + if (balance > bestMintSats) { + bestMintSats = balance + bestMint = mint + } + } + + return NutzapFunding( + target = NutzapTarget(mintUrl = bestMint, recipientP2pkPubkeyHex = recipientPubkeyHex), + bestSingleMintSats = bestMintSats, + totalWalletSats = entries.sumOf { it.content.totalAmount() }, ) } @@ -1133,3 +1166,16 @@ data class NutzapTarget( val mintUrl: String, val recipientP2pkPubkeyHex: String, ) + +/** + * Nutzap funding snapshot for a recipient — the [target] mint to spend from + * plus the balance figures the zap picker uses to classify a given amount: + * - `amount <= bestSingleMintSats` → fundable instantly from one shared mint + * - `amount <= totalWalletSats` → fundable only after a mint reload/rebalance + * - otherwise → out of reach with the current balance + */ +data class NutzapFunding( + val target: NutzapTarget, + val bestSingleMintSats: Long, + val totalWalletSats: Long, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt index 019371b1e9..76b09c1316 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt @@ -46,12 +46,41 @@ data class RailCapability( val hasCashu: Boolean, val hasLightning: Boolean, val hasOnchain: Boolean, + /** Largest balance held in any single mint shared with the recipient. */ + val cashuBestSingleMintSats: Long = 0L, + /** Total cashu balance across all our mints (reachable via reload/rebalance). */ + val cashuTotalWalletSats: Long = 0L, ) { + /** + * Classify a cashu nutzap of [amountSats] for the unified amount chip. + * Cashu is the one rail whose balance is free to read synchronously, so + * this is a precise per-amount status rather than the optimistic + * recipient-only gating used for Lightning and on-chain. + */ + fun cashuStatus(amountSats: Long): CashuRailStatus = + when { + !hasCashu -> CashuRailStatus.UNAVAILABLE + amountSats <= cashuBestSingleMintSats -> CashuRailStatus.FUNDED + amountSats <= cashuTotalWalletSats -> CashuRailStatus.NEEDS_RELOAD + else -> CashuRailStatus.IMPOSSIBLE + } + companion object { val NONE = RailCapability(hasCashu = false, hasLightning = false, hasOnchain = false) } } +/** + * Per-amount cashu spendability for the unified zap chip. + * - [FUNDED]: a single shared mint already covers the amount — instant, no fee. + * - [NEEDS_RELOAD]: total wallet balance covers it, but no single shared mint + * does — needs a mint reload/rebalance first. + * - [IMPOSSIBLE]: not enough cashu anywhere — a reload can't help. + * - [UNAVAILABLE]: the recipient can't receive cashu at all (no kind:10019 / + * no shared mint). + */ +enum class CashuRailStatus { FUNDED, NEEDS_RELOAD, IMPOSSIBLE, UNAVAILABLE } + object RailCapabilityResolver { /** * Resolve [RailCapability] for [baseNote]. Reads are synchronous from @@ -59,10 +88,11 @@ object RailCapabilityResolver { * inside a composable. * * Rules: - * - **Cashu**: any pubkey-based recipient (author + `ZapSplitSetup` - * splits) has a kind:10019 with a P2PK pubkey AND shares at least one - * mint with our wallet. Delegates to [CashuWalletState.peekNutzapTarget] - * which already enforces all three conditions. + * - **Cashu**: the note **author** has a kind:10019 with a P2PK pubkey + * AND shares at least one mint with our wallet. Computed against the + * author only (not `zap` splits) because [CashuWalletState.sendNutzap] + * pays the author. Delegates to [CashuWalletState.peekNutzapFunding], + * which also reports per-mint balances for amount-level gating. * - **Lightning**: any pubkey recipient has lud16/lud06 in their kind:0, * OR the note has at least one direct `ZapSplitSetupLnAddress` split. * - **On-chain**: at least one pubkey-based recipient exists. NIP-BC @@ -92,7 +122,12 @@ object RailCapabilityResolver { return RailCapability.NONE } - val hasCashu = pubKeyRecipients.any { cashuState.peekNutzapTarget(it) != null } + // Cashu is computed against the author only: sendNutzap pays + // baseNote.author (it does not fan a nutzap across `zap` splits), so + // gating on a split recipient's wallet would offer a chip the send + // path can't honor. + val cashuFunding = author?.let { cashuState.peekNutzapFunding(it) } + val hasCashu = cashuFunding != null val hasLightning = lnAddressOnlySplits.isNotEmpty() || @@ -108,6 +143,8 @@ object RailCapabilityResolver { hasCashu = hasCashu, hasLightning = hasLightning, hasOnchain = hasOnchain, + cashuBestSingleMintSats = cashuFunding?.bestSingleMintSats ?: 0L, + cashuTotalWalletSats = cashuFunding?.totalWalletSats ?: 0L, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt index 0a594d4131..772948a63d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt @@ -207,10 +207,6 @@ fun ReusableZapButton( onchainZapAmount = amount showOnchainDialog = true }, - nutzapEnabled = - baseNote.author?.pubkeyHex?.let { recipient -> - accountViewModel.account.cashuWalletState.peekNutzapTarget(recipient) != null - } ?: false, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 65abc41c87..35d1626c15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -38,6 +38,7 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -52,6 +53,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults @@ -83,6 +85,7 @@ import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterStart import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext @@ -111,6 +114,8 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ReactionRowAction import com.vitorpamplona.amethyst.model.ReactionRowItem import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.zap.CashuRailStatus +import com.vitorpamplona.amethyst.model.zap.RailCapability import com.vitorpamplona.amethyst.model.zap.RailCapabilityResolver import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription @@ -1887,21 +1892,9 @@ fun ZapAmountChoicePopup( accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices .collectAsStateWithLifecycle() - // Hide chips for rails the recipient(s) can't actually receive on. For a - // single-author note this gates against the author's kind:0 / kind:10019; - // for a note with `zap` split tags it considers every split recipient so - // splits where (e.g.) only one of three has a lud16 still surface the LN - // chips. Recomputed only when the note id changes — the underlying flows - // (CashuWalletState, LocalCache user metadata) are stable for the life of - // the popup, which is usually open for under a second. - val railCapability = - remember(baseNote) { - RailCapabilityResolver.peek(baseNote, accountViewModel.account.cashuWalletState) - } - ZapAmountChoicePopup( baseNote = baseNote, - zapAmountChoices = if (railCapability.hasLightning) zapAmountChoices else persistentListOf(), + zapAmountChoices = zapAmountChoices, accountViewModel = accountViewModel, popupYOffset = popupYOffset, onZapStarts = onZapStarts, @@ -1910,14 +1903,9 @@ fun ZapAmountChoicePopup( onError = onError, onProgress = onProgress, onPayViaIntent = onPayViaIntent, - onchainZapAmountChoices = - if (onOnchainAmount != null && railCapability.hasOnchain) { - onchainZapAmountChoices - } else { - persistentListOf() - }, + onchainZapAmountChoices = onchainZapAmountChoices, onOnchainAmount = onOnchainAmount ?: {}, - nutzapEnabled = railCapability.hasCashu, + onchainSupported = onOnchainAmount != null, ) } @@ -1935,13 +1923,33 @@ fun ZapAmountChoicePopup( onPayViaIntent: (ImmutableList) -> Unit, onchainZapAmountChoices: ImmutableList = persistentListOf(), onOnchainAmount: (Long?) -> Unit = {}, - nutzapEnabled: Boolean = false, + onchainSupported: Boolean = true, ) { + // One chip per amount; the chip itself shows which rails can pay it. Merge + // the (historically separate) Lightning and on-chain amount presets into a + // single sorted set of choices. Rail availability per amount is decided in + // [UnifiedZapAmountChip] from [railCapability], so the lists no longer need + // to be pre-emptied per rail. + // + // [railCapability] gates which rails the recipient can receive on (plus the + // sender's cashu balance, which is free to read). It's recomputed only when + // the note changes — the underlying cache/wallet flows are stable for the + // ~1s the popup is open. A caller that can't drive the on-chain dialog + // (onchainSupported == false) masks that rail off here. + val railCapability = + remember(baseNote, onchainSupported) { + val rc = RailCapabilityResolver.peek(baseNote, accountViewModel.account.cashuWalletState) + if (onchainSupported) rc else rc.copy(hasOnchain = false) + } + val amountChoices = + remember(zapAmountChoices, onchainZapAmountChoices) { + (zapAmountChoices + onchainZapAmountChoices).distinct().sorted().toImmutableList() + } val visibilityState = rememberVisibilityState(onDismiss) ZapAmountChoicePopup( baseNote, - zapAmountChoices, - onchainZapAmountChoices, + amountChoices, + railCapability, accountViewModel, popupYOffset, visibilityState, @@ -1951,7 +1959,6 @@ fun ZapAmountChoicePopup( onError, onProgress, onPayViaIntent, - nutzapEnabled, ) } @@ -1959,8 +1966,8 @@ fun ZapAmountChoicePopup( @Composable fun ZapAmountChoicePopup( baseNote: Note, - zapAmountChoices: ImmutableList, - onchainZapAmountChoices: ImmutableList, + amountChoices: ImmutableList, + railCapability: RailCapability, accountViewModel: AccountViewModel, popupYOffset: Dp, visibilityState: MutableTransitionState, @@ -1970,7 +1977,6 @@ fun ZapAmountChoicePopup( onError: (title: String, text: String, user: User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, - nutzapEnabled: Boolean = false, ) { val context = LocalContext.current val yOffset = with(LocalDensity.current) { -popupYOffset.toPx().toInt() } @@ -1987,9 +1993,9 @@ fun ZapAmountChoicePopup( exit = popupAnimationExit, ) { ZapAmountChoicePopupContent( - zapAmountChoices = zapAmountChoices, - onchainZapAmountChoices = onchainZapAmountChoices, - onZap = { amountInSats -> + amountChoices = amountChoices, + railCapability = railCapability, + onLightningZap = { amountInSats -> onZapStarts() accountViewModel.zap( baseNote, @@ -2004,12 +2010,6 @@ fun ZapAmountChoicePopup( ) visibilityState.targetState = false }, - onChangeAmount = onChangeAmount, - onOnchainAmount = { amount -> - onOnchainAmount(amount) - visibilityState.targetState = false - }, - nutzapAmountChoices = if (nutzapEnabled) zapAmountChoices else persistentListOf(), onNutzap = { amountInSats -> onZapStarts() // Instant click feedback. Without this initial nudge @@ -2025,6 +2025,11 @@ fun ZapAmountChoicePopup( ) visibilityState.targetState = false }, + onOnchainAmount = { amount -> + onOnchainAmount(amount) + visibilityState.targetState = false + }, + onChangeAmount = onChangeAmount, ) } } @@ -2033,13 +2038,12 @@ fun ZapAmountChoicePopup( @OptIn(ExperimentalLayoutApi::class) @Composable fun ZapAmountChoicePopupContent( - zapAmountChoices: ImmutableList, - onZap: (Long) -> Unit, + amountChoices: ImmutableList, + railCapability: RailCapability, + onLightningZap: (Long) -> Unit, + onNutzap: (Long) -> Unit, + onOnchainAmount: (Long?) -> Unit, onChangeAmount: () -> Unit, - onchainZapAmountChoices: ImmutableList = persistentListOf(), - onOnchainAmount: (Long?) -> Unit = {}, - nutzapAmountChoices: ImmutableList = persistentListOf(), - onNutzap: (Long) -> Unit = {}, ) { Box(HalfPadding, contentAlignment = Center) { ElevatedCard( @@ -2053,29 +2057,14 @@ fun ZapAmountChoicePopupContent( verticalArrangement = Arrangement.Center, itemVerticalAlignment = CenterVertically, ) { - // Order: Cashu first (instant, no fees), then Lightning, - // then on-chain — matches the recipient-capability fallback - // order used elsewhere (RailCapabilityResolver) so the - // "happiest path" rail surfaces first for the eye. - nutzapAmountChoices.forEach { amountInSats -> - NutzapAmountChip( + amountChoices.forEach { amountInSats -> + UnifiedZapAmountChip( amountInSats = amountInSats, - onClick = { onNutzap(amountInSats) }, - onLongClick = onChangeAmount, - ) - } - zapAmountChoices.forEach { amountInSats -> - ZapAmountChip( - amountInSats = amountInSats, - onClick = { onZap(amountInSats) }, - onLongClick = onChangeAmount, - ) - } - onchainZapAmountChoices.forEach { amountInSats -> - OnchainZapAmountChip( - amountInSats = amountInSats, - onClick = { onOnchainAmount(amountInSats) }, - onLongClick = onChangeAmount, + railCapability = railCapability, + onLightningZap = onLightningZap, + onNutzap = onNutzap, + onOnchainAmount = onOnchainAmount, + onChangeAmount = onChangeAmount, ) } ClickableBox( @@ -2098,115 +2087,128 @@ fun ZapAmountChoicePopupContent( } } +/** + * Floor for the on-chain rail. Mirrors `MIN_ONCHAIN_ZAP_SATS` in + * `OnchainZapSendDialog` (stricter than the protocol dust threshold); kept in + * sync by hand because that one is private to the dialog. Below this the + * on-chain logo is not offered for an amount. + */ +private const val MIN_ONCHAIN_ZAP_SATS = 1_000L + +/** + * One pill per amount, showing a tappable logo for every rail that can pay it: + * - **Cashu** only when a single shared mint is already funded for the amount + * (the one rail whose balance is free to read). Underfunded/reload states + * are intentionally not offered yet. + * - **Lightning** optimistically whenever the recipient can receive — an + * external wallet can pay any invoice, so we don't gate on a sender balance. + * - **On-chain** when a backend is configured and the amount clears + * [MIN_ONCHAIN_ZAP_SATS]; UTXO sufficiency stays a send-time check. + * + * Tapping the amount fires the cheapest/fastest available rail (cashu → + * Lightning → on-chain); tapping a specific logo forces that rail. Long-press + * opens the amount-preset editor. A pill with no payable rail is not rendered. + */ @OptIn(ExperimentalFoundationApi::class) @Composable -private fun NutzapAmountChip( +private fun UnifiedZapAmountChip( amountInSats: Long, - onClick: () -> Unit, - onLongClick: () -> Unit, + railCapability: RailCapability, + onLightningZap: (Long) -> Unit, + onNutzap: (Long) -> Unit, + onOnchainAmount: (Long?) -> Unit, + onChangeAmount: () -> Unit, ) { + val cashuReady = railCapability.cashuStatus(amountInSats) == CashuRailStatus.FUNDED + val lightningReady = railCapability.hasLightning + val onchainReady = railCapability.hasOnchain && amountInSats >= MIN_ONCHAIN_ZAP_SATS + + if (!cashuReady && !lightningReady && !onchainReady) return + + val defaultAction: () -> Unit = + when { + cashuReady -> { + { onNutzap(amountInSats) } + } + lightningReady -> { + { onLightningZap(amountInSats) } + } + else -> { + { onOnchainAmount(amountInSats) } + } + } + Surface( shape = ButtonBorder, - color = MaterialTheme.colorScheme.tertiary, + color = MaterialTheme.colorScheme.surface, modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp), ) { Row( modifier = Modifier - .combinedClickable(onClick = onClick, onLongClick = onLongClick) - .padding(horizontal = 12.dp, vertical = 6.dp), + .combinedClickable(onClick = defaultAction, onLongClick = onChangeAmount) + .padding(start = 12.dp, end = 6.dp, top = 6.dp, bottom = 6.dp), verticalAlignment = CenterVertically, ) { - // CustomHashTagIcons.Cashu ships a multi-tone Cashu logo; using - // tint=Unspecified preserves it instead of flattening to onTertiary - // (which would lose the cashu-orange brand cue that distinguishes - // this chip from the orange Lightning bolt next to it). - Material3Icon( - imageVector = CustomHashTagIcons.Cashu, - contentDescription = stringRes(R.string.nutzap), - modifier = Size18Modifier, - tint = Color.Unspecified, - ) - Spacer(Modifier.width(2.dp)) Text( text = showAmount(amountInSats.toBigDecimal().setScale(1)), - color = MaterialTheme.colorScheme.onTertiary, + color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center, ) + Spacer(Modifier.width(6.dp)) + if (cashuReady) { + RailLogo(onClick = { onNutzap(amountInSats) }) { + // CustomHashTagIcons.Cashu is a multi-tone logo; tint + // Unspecified preserves its cashu-orange brand cue. + Material3Icon( + imageVector = CustomHashTagIcons.Cashu, + contentDescription = stringRes(R.string.nutzap), + modifier = Size18Modifier, + tint = Color.Unspecified, + ) + } + } + if (lightningReady) { + RailLogo(onClick = { onLightningZap(amountInSats) }) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = null, + modifier = Size18Modifier, + tint = BitcoinOrange, + ) + } + } + if (onchainReady) { + RailLogo(onClick = { onOnchainAmount(amountInSats) }) { + Icon( + symbol = MaterialSymbols.CurrencyBitcoin, + contentDescription = null, + modifier = Size18Modifier, + tint = BitcoinOrange, + ) + } + } } } } @OptIn(ExperimentalFoundationApi::class) @Composable -private fun ZapAmountChip( - amountInSats: Long, +private fun RailLogo( onClick: () -> Unit, - onLongClick: () -> Unit, + content: @Composable () -> Unit, ) { - Surface( - shape = ButtonBorder, - color = BitcoinOrange, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp), + Box( + modifier = + Modifier + .padding(horizontal = 3.dp) + .clip(CircleShape) + .clickable(onClick = onClick) + .padding(2.dp), + contentAlignment = Center, ) { - Row( - modifier = - Modifier - .combinedClickable(onClick = onClick, onLongClick = onLongClick) - .padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = CenterVertically, - ) { - Icon( - symbol = MaterialSymbols.Bolt, - contentDescription = null, - modifier = Size18Modifier, - tint = Color.White, - ) - Spacer(Modifier.width(2.dp)) - Text( - text = showAmount(amountInSats.toBigDecimal().setScale(1)), - color = Color.White, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - ) - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun OnchainZapAmountChip( - amountInSats: Long, - onClick: () -> Unit, - onLongClick: () -> Unit, -) { - Surface( - shape = ButtonBorder, - color = BitcoinOrange, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp), - ) { - Row( - modifier = - Modifier - .combinedClickable(onClick = onClick, onLongClick = onLongClick) - .padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = CenterVertically, - ) { - Icon( - symbol = MaterialSymbols.CurrencyBitcoin, - contentDescription = null, - modifier = Size18Modifier, - tint = Color.White, - ) - Spacer(Modifier.width(2.dp)) - Text( - text = showAmount(amountInSats.toBigDecimal().setScale(1)), - color = Color.White, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - ) - } + content() } } @@ -2215,11 +2217,19 @@ private fun OnchainZapAmountChip( fun ZapAmountChoicePopupPreview() { ThemeComparisonColumn { ZapAmountChoicePopupContent( - zapAmountChoices = persistentListOf(50L, 100L, 500L, 1_000L, 5_000L, 10_000L, 100_000L), - onchainZapAmountChoices = persistentListOf(10_000L, 50_000L, 250_000L), - onZap = {}, - onChangeAmount = {}, + amountChoices = persistentListOf(50L, 100L, 500L, 1_000L, 5_000L, 10_000L, 100_000L), + railCapability = + RailCapability( + hasCashu = true, + hasLightning = true, + hasOnchain = true, + cashuBestSingleMintSats = 1_000L, + cashuTotalWalletSats = 10_000L, + ), + onLightningZap = {}, + onNutzap = {}, onOnchainAmount = {}, + onChangeAmount = {}, ) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/zap/RailCapabilityCashuStatusTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/zap/RailCapabilityCashuStatusTest.kt new file mode 100644 index 0000000000..c0dd395ae8 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/zap/RailCapabilityCashuStatusTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.zap + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Locks in how the unified zap chip classifies a cashu nutzap per amount. + * This is the precise (free-to-read) gating the chip relies on, so the + * boundaries between funded / reload / impossible matter. + */ +class RailCapabilityCashuStatusTest { + private fun caps( + hasCashu: Boolean = true, + bestSingleMint: Long = 0L, + totalWallet: Long = 0L, + ) = RailCapability( + hasCashu = hasCashu, + hasLightning = false, + hasOnchain = false, + cashuBestSingleMintSats = bestSingleMint, + cashuTotalWalletSats = totalWallet, + ) + + @Test + fun unavailableWhenRecipientCannotReceiveCashu() { + assertEquals( + CashuRailStatus.UNAVAILABLE, + caps(hasCashu = false, bestSingleMint = 10_000L, totalWallet = 10_000L).cashuStatus(100L), + ) + } + + @Test + fun fundedWhenSingleMintCoversAmount() { + val c = caps(bestSingleMint = 1_000L, totalWallet = 5_000L) + assertEquals(CashuRailStatus.FUNDED, c.cashuStatus(500L)) + // Exact boundary is funded. + assertEquals(CashuRailStatus.FUNDED, c.cashuStatus(1_000L)) + } + + @Test + fun needsReloadWhenOnlyTotalCoversAmount() { + // Funds exist, but spread across mints so no single shared mint covers it. + val c = caps(bestSingleMint = 1_000L, totalWallet = 5_000L) + assertEquals(CashuRailStatus.NEEDS_RELOAD, c.cashuStatus(1_001L)) + assertEquals(CashuRailStatus.NEEDS_RELOAD, c.cashuStatus(5_000L)) + } + + @Test + fun impossibleWhenTotalCannotCoverAmount() { + val c = caps(bestSingleMint = 1_000L, totalWallet = 5_000L) + assertEquals(CashuRailStatus.IMPOSSIBLE, c.cashuStatus(5_001L)) + } + + @Test + fun emptyWalletWithReceivingRecipientIsImpossibleNotUnavailable() { + // Recipient can receive (hasCashu) but we hold nothing anywhere. + assertEquals(CashuRailStatus.IMPOSSIBLE, caps().cashuStatus(1L)) + } +} From 6f7cacf84bc58ecaf8fbf14098bf1baa221d2786 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 18:28:48 +0000 Subject: [PATCH 02/27] refactor(zap): merge on-chain amount presets into the single zap-amount list The on-chain rail no longer has its own editable preset list. There is now one zap-amount list used by every rail; on-chain simply filters it by the shared MIN_ONCHAIN_ZAP_SATS floor (1000 sat), and the unified chip already gates each logo per amount. - Settings: drop the public onchainZapAmountChoices StateFlow and its editor section in the Update Zap Amount dialog. The serialized field is kept for backward/cross-client (NIP-78) compatibility and migration: on load the two saved lists are unioned into zapAmountChoices (mergeZapAmounts), and on save the on-chain-eligible subset is written back so older clients still get sensible on-chain presets. The round-trip is idempotent. - updateZapAmounts / changeOnchainZapAmounts lose the separate on-chain parameter throughout (Account, AccountViewModel, UpdateZapAmountViewModel). - OnchainZapSendDialog draws its quick presets from the single list filtered by the minimum. - MIN_ONCHAIN_ZAP_SATS is now a single shared constant in the model, replacing the copies previously private to OnchainZapSendDialog and ReactionsRow. - Remove the now-unused on-chain amount strings; clarify the zap-amounts explainer to note amounts apply to all rails. Adds ZapAmountMergeTest covering the union/dedup/idempotent-round-trip. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../vitorpamplona/amethyst/model/Account.kt | 2 - .../amethyst/model/AccountSettings.kt | 9 -- .../amethyst/model/AccountSyncedSettings.kt | 25 +++-- .../model/AccountSyncedSettingsInternal.kt | 15 +++ .../amethyst/model/zap/RailCapability.kt | 4 +- .../ui/components/ReusableZapButton.kt | 6 -- .../amethyst/ui/note/ReactionsRow.kt | 28 ++---- .../amethyst/ui/note/UpdateZapAmountDialog.kt | 91 +------------------ .../ui/note/UpdateZapAmountViewModel.kt | 21 +---- .../ui/screen/loggedIn/AccountViewModel.kt | 3 +- .../loggedIn/wallet/OnchainZapSendDialog.kt | 13 +-- amethyst/src/main/res/values/strings.xml | 5 +- .../amethyst/model/ZapAmountMergeTest.kt | 71 +++++++++++++++ 13 files changed, 120 insertions(+), 173 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/ZapAmountMergeTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 407cd48b97..af22c00ff3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -652,14 +652,12 @@ class Account( suspend fun updateZapAmounts( amountSet: List, - onchainAmountSet: List, selectedZapType: LnZapEvent.ZapType, nip47Update: Nip47WalletConnect.Nip47URINorm?, ) { var changed = false if (settings.changeZapAmounts(amountSet)) changed = true - if (settings.changeOnchainZapAmounts(onchainAmountSet)) changed = true if (settings.changeDefaultZapType(selectedZapType)) changed = true if (settings.changeZapPaymentRequest(nip47Update)) changed = true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 7e53a66456..2f226fab7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -291,15 +291,6 @@ class AccountSettings( return false } - fun changeOnchainZapAmounts(newAmounts: List): Boolean { - if (syncedSettings.zaps.onchainZapAmountChoices.value != newAmounts) { - syncedSettings.zaps.onchainZapAmountChoices.tryEmit(newAmounts.toImmutableList()) - saveAccountSettings() - return true - } - return false - } - fun changeReactionTypes(newTypes: List): Boolean { if (syncedSettings.reactions.reactionChoices.value != newTypes) { syncedSettings.reactions.reactionChoices.tryEmit(newTypes.toImmutableList()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index c8fa71e767..e5f6367fe7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -39,8 +39,7 @@ class AccountSyncedSettings( ) val zaps = AccountZapPreferences( - MutableStateFlow(internalSettings.zaps.zapAmountChoices.toImmutableList()), - MutableStateFlow(internalSettings.zaps.onchainZapAmountChoices.toImmutableList()), + MutableStateFlow(mergeZapAmounts(internalSettings.zaps).toImmutableList()), MutableStateFlow(internalSettings.zaps.defaultZapType), ) val languages = @@ -70,7 +69,10 @@ class AccountSyncedSettings( zaps = AccountZapPreferencesInternal( zaps.zapAmountChoices.value, - zaps.onchainZapAmountChoices.value, + // Write the on-chain-eligible subset into the legacy field so + // older clients still get sensible on-chain presets. Unioning + // it back on load is idempotent (subset ⊆ full list). + zaps.zapAmountChoices.value.filter { it >= MIN_ONCHAIN_ZAP_SATS }, zaps.defaultZapType.value, ), languages = @@ -104,16 +106,11 @@ class AccountSyncedSettings( reactions.reactionRowItems.tryEmit(newReactionRowItems) } - val newZapChoices = syncedSettingsInternal.zaps.zapAmountChoices.toImmutableList() + val newZapChoices = mergeZapAmounts(syncedSettingsInternal.zaps).toImmutableList() if (!equalImmutableLists(zaps.zapAmountChoices.value, newZapChoices)) { zaps.zapAmountChoices.tryEmit(newZapChoices) } - val newOnchainZapChoices = syncedSettingsInternal.zaps.onchainZapAmountChoices.toImmutableList() - if (!equalImmutableLists(zaps.onchainZapAmountChoices.value, newOnchainZapChoices)) { - zaps.onchainZapAmountChoices.tryEmit(newOnchainZapChoices) - } - if (zaps.defaultZapType.value != syncedSettingsInternal.zaps.defaultZapType) { zaps.defaultZapType.tryEmit(syncedSettingsInternal.zaps.defaultZapType) } @@ -182,10 +179,18 @@ class AccountVideoPlayerPreferences( @Stable class AccountZapPreferences( var zapAmountChoices: MutableStateFlow>, - var onchainZapAmountChoices: MutableStateFlow>, val defaultZapType: MutableStateFlow, ) +/** + * Union the (historically separate) zap and on-chain preset lists into the + * single sorted set the app now uses everywhere. Preserves amounts customized + * on either side and amounts synced from an older client that still writes the + * legacy `onchainZapAmountChoices`. Idempotent: [AccountSyncedSettings.toInternal] + * re-derives the on-chain field as a subset of this list. + */ +internal fun mergeZapAmounts(zaps: AccountZapPreferencesInternal): List = (zaps.zapAmountChoices + zaps.onchainZapAmountChoices).distinct().sorted() + @Stable class AccountLanguagePreferences( var dontTranslateFrom: MutableStateFlow>, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index 29ed96a2c0..8b8aa10b2b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -41,6 +41,16 @@ val DefaultZapAmounts = listOf(21L, 50L, 100L) val DefaultOnchainZapAmounts = listOf(5_000L) val DefaultReportWarningThreshold = 5 +/** + * Product floor for the on-chain rail — stricter than the protocol-level + * dust threshold (OnchainZapBuilder.DUST_THRESHOLD_SATS). The unified zap + * picker offers the on-chain logo only for amounts at or above this, and the + * on-chain send dialog draws its presets from the single zap-amount list + * filtered by it. Single source of truth for everywhere that gates on-chain + * by amount. + */ +const val MIN_ONCHAIN_ZAP_SATS = 1_000L + @Serializable enum class ReactionRowAction { Reply, @@ -154,6 +164,11 @@ class AccountReactionPreferencesInternal( @Serializable class AccountZapPreferencesInternal( var zapAmountChoices: List = DefaultZapAmounts, + // Legacy field: the on-chain rail no longer has its own editable preset + // list — amounts live in [zapAmountChoices] and on-chain just filters by + // [MIN_ONCHAIN_ZAP_SATS]. Kept (de)serialized so older clients still sync + // and so the on-chain-eligible subset round-trips back for them; on load it + // is unioned into [zapAmountChoices]. See AccountSyncedSettings. var onchainZapAmountChoices: List = DefaultOnchainZapAmounts, val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt index 76b09c1316..ffc8de96d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt @@ -99,8 +99,8 @@ object RailCapabilityResolver { * derives the Taproot address from the recipient pubkey, so any nostr * pubkey is payable; lnAddress-only recipients are not (they have no * pubkey to tweak). The sender's onchain wallet availability is a - * *sender* concern — handled elsewhere by the popup gating the chip - * on `onchainZapAmountChoices` and the dialog on `LocalCache.onchainBackend`. + * *sender* concern — handled elsewhere by the chip gating on-chain by + * `MIN_ONCHAIN_ZAP_SATS` and the dialog on `LocalCache.onchainBackend`. */ fun peek( baseNote: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt index 772948a63d..a5757930c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt @@ -44,7 +44,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -110,10 +109,6 @@ fun ReusableZapButton( var onchainZapAmount by remember { mutableStateOf(null) } var showOnchainDialog by remember { mutableStateOf(false) } - val onchainZapAmountChoices by - accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices - .collectAsStateWithLifecycle() - // Makes sure the user is loaded to get his ln address ahead of time (for DVM buttons) if (config.showUserFinderSubscription) { baseNote.author?.let { author -> @@ -201,7 +196,6 @@ fun ReusableZapButton( nav.nav(Route.ManualZapSplitPayment(uid)) } }, - onchainZapAmountChoices = onchainZapAmountChoices, onOnchainAmount = { amount -> wantsToZap = null onchainZapAmount = amount diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 35d1626c15..681b3f836f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -110,6 +110,7 @@ import com.vitorpamplona.amethyst.commons.hashtags.Cashu import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.MIN_ONCHAIN_ZAP_SATS import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ReactionRowAction import com.vitorpamplona.amethyst.model.ReactionRowItem @@ -1888,9 +1889,6 @@ fun ZapAmountChoicePopup( val zapAmountChoices by accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices .collectAsStateWithLifecycle() - val onchainZapAmountChoices by - accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices - .collectAsStateWithLifecycle() ZapAmountChoicePopup( baseNote = baseNote, @@ -1903,7 +1901,6 @@ fun ZapAmountChoicePopup( onError = onError, onProgress = onProgress, onPayViaIntent = onPayViaIntent, - onchainZapAmountChoices = onchainZapAmountChoices, onOnchainAmount = onOnchainAmount ?: {}, onchainSupported = onOnchainAmount != null, ) @@ -1921,15 +1918,14 @@ fun ZapAmountChoicePopup( onError: (title: String, text: String, user: User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, - onchainZapAmountChoices: ImmutableList = persistentListOf(), onOnchainAmount: (Long?) -> Unit = {}, onchainSupported: Boolean = true, ) { - // One chip per amount; the chip itself shows which rails can pay it. Merge - // the (historically separate) Lightning and on-chain amount presets into a - // single sorted set of choices. Rail availability per amount is decided in - // [UnifiedZapAmountChip] from [railCapability], so the lists no longer need - // to be pre-emptied per rail. + // One chip per amount; the chip itself shows which rails can pay it. + // [zapAmountChoices] is already the single merged+sorted preset list (the + // on-chain amounts were folded in at the settings layer), so rail + // availability per amount is decided in [UnifiedZapAmountChip] from + // [railCapability] rather than by keeping separate per-rail lists. // // [railCapability] gates which rails the recipient can receive on (plus the // sender's cashu balance, which is free to read). It's recomputed only when @@ -1942,8 +1938,8 @@ fun ZapAmountChoicePopup( if (onchainSupported) rc else rc.copy(hasOnchain = false) } val amountChoices = - remember(zapAmountChoices, onchainZapAmountChoices) { - (zapAmountChoices + onchainZapAmountChoices).distinct().sorted().toImmutableList() + remember(zapAmountChoices) { + zapAmountChoices.distinct().sorted().toImmutableList() } val visibilityState = rememberVisibilityState(onDismiss) ZapAmountChoicePopup( @@ -2087,14 +2083,6 @@ fun ZapAmountChoicePopupContent( } } -/** - * Floor for the on-chain rail. Mirrors `MIN_ONCHAIN_ZAP_SATS` in - * `OnchainZapSendDialog` (stricter than the protocol dust threshold); kept in - * sync by hand because that one is private to the dialog. Below this the - * on-chain logo is not offered for an amount. - */ -private const val MIN_ONCHAIN_ZAP_SATS = 1_000L - /** * One pill per amount, showing a tappable logo for every rail that can pay it: * - **Cashu** only when a single shared mint is already funded for the amount diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index ca6750051e..c1cc450e08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -339,96 +339,7 @@ fun UpdateZapAmountContent( ) } - // ── Section 3: Quick On-chain Zap Amounts ───────────────────────────── - - Text( - text = stringRes(R.string.quick_zap_amounts_onchain), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.titleSmall, - modifier = SettingsCategorySpacingModifier, - ) - Text( - text = stringRes(R.string.quick_zap_amounts_onchain_explainer), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(bottom = 6.dp), - ) - - FlowRow( - modifier = - Modifier - .fillMaxWidth() - .animateContentSize(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - postViewModel.onchainAmountSet.forEach { amountInSats -> - InputChip( - selected = false, - onClick = { postViewModel.removeOnchainAmount(amountInSats) }, - label = { - Text( - text = "₿ ${showAmount(amountInSats.toBigDecimal().setScale(1))}", - ) - }, - trailingIcon = { - Icon( - symbol = MaterialSymbols.Close, - contentDescription = stringRes(R.string.remove), - modifier = Modifier.size(InputChipDefaults.AvatarSize), - ) - }, - colors = - InputChipDefaults.inputChipColors( - containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), - labelColor = MaterialTheme.colorScheme.primary, - trailingIconColor = MaterialTheme.colorScheme.primary, - ), - ) - } - } - - Spacer(modifier = Modifier.height(6.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.new_amount_in_sats_onchain)) }, - value = postViewModel.nextOnchainAmount, - onValueChange = { postViewModel.nextOnchainAmount = it }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Number, - ), - placeholder = { - Text( - text = "5000, 25000, 100000", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - IconButton( - onClick = postViewModel::addOnchainAmount, - shape = ButtonBorder, - enabled = postViewModel.nextOnchainAmount.text.isNotBlank(), - ) { - Icon( - symbol = MaterialSymbols.AddCircle, - contentDescription = stringRes(R.string.add), - modifier = Size20Modifier, - ) - } - }, - singleLine = true, - modifier = Modifier.weight(1f), - ) - } - - // ── Section 4: Nostr Wallet Connect ─────────────────────────────────── + // ── Section 3: Nostr Wallet Connect ─────────────────────────────────── HorizontalDivider( modifier = Modifier.padding(vertical = 16.dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt index 963e6c2864..8d50017d9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt @@ -41,8 +41,6 @@ class UpdateZapAmountViewModel : ViewModel() { var nextAmount by mutableStateOf(TextFieldValue("")) var amountSet by mutableStateOf(listOf()) - var nextOnchainAmount by mutableStateOf(TextFieldValue("")) - var onchainAmountSet by mutableStateOf(listOf()) var walletConnectRelay by mutableStateOf(TextFieldValue("")) var walletConnectPubkey by mutableStateOf(TextFieldValue("")) var walletConnectSecret by mutableStateOf(TextFieldValue("")) @@ -61,7 +59,6 @@ class UpdateZapAmountViewModel : ViewModel() { fun load() { this.amountSet = accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value - this.onchainAmountSet = accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices.value this.selectedZapType = accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value val nip47 = accountViewModel.account.settings.defaultZapPaymentRequest() @@ -86,19 +83,6 @@ class UpdateZapAmountViewModel : ViewModel() { amountSet = amountSet - amount } - fun addOnchainAmount() { - val newValue = nextOnchainAmount.text.trim().toLongOrNull() - if (newValue != null) { - onchainAmountSet = onchainAmountSet + newValue - } - - nextOnchainAmount = TextFieldValue("") - } - - fun removeOnchainAmount(amount: Long) { - onchainAmountSet = onchainAmountSet - amount - } - fun sendPost() { accountViewModel.launchSigner { sendPostSuspend() @@ -132,15 +116,13 @@ class UpdateZapAmountViewModel : ViewModel() { null } - accountViewModel.account.updateZapAmounts(amountSet, onchainAmountSet, selectedZapType, nip47Update) + accountViewModel.account.updateZapAmounts(amountSet, selectedZapType, nip47Update) nextAmount = TextFieldValue("") - nextOnchainAmount = TextFieldValue("") } fun cancel() { nextAmount = TextFieldValue("") - nextOnchainAmount = TextFieldValue("") } fun hasChanged(): Boolean { @@ -148,7 +130,6 @@ class UpdateZapAmountViewModel : ViewModel() { return ( selectedZapType != accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value || amountSet != accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value || - onchainAmountSet != accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices.value || walletConnectPubkey.text != (defaultUri?.pubKeyHex ?: "") || walletConnectRelay.text != (defaultUri?.relayUri?.url ?: "") || walletConnectSecret.text != (defaultUri?.secret ?: "") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 1c254fc462..3f034ebac0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1297,10 +1297,9 @@ class AccountViewModel( fun updateZapAmounts( amountSet: List, - onchainAmountSet: List, selectedZapType: LnZapEvent.ZapType, nip47Update: Nip47WalletConnect.Nip47URINorm?, - ) = launchSigner { account.updateZapAmounts(amountSet, onchainAmountSet, selectedZapType, nip47Update) } + ) = launchSigner { account.updateZapAmounts(amountSet, selectedZapType, nip47Update) } fun toggleDontTranslateFrom(languageCode: String) = launchSigner { account.toggleDontTranslateFrom(languageCode) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt index 000ca33990..3693ab853f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -74,6 +74,7 @@ import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSplitter import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.MIN_ONCHAIN_ZAP_SATS import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.namecoin.NamecoinResolutionRow import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav @@ -99,13 +100,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.text.NumberFormat -// UX floor for onchain zaps. Below this any on-chain transaction is dominated -// by miner fees — the recipient nets close to nothing even at low fee rates, -// so quietly funneling the user to a Lightning zap is the friendlier outcome. -// This is stricter than the protocol-level [OnchainZapBuilder.DUST_THRESHOLD_SATS] -// (330 sats), which only guards against creating outputs the network rejects. -private const val MIN_ONCHAIN_ZAP_SATS = 1_000L - private enum class FeeTier( val label: String, val etaLabel: String, @@ -213,8 +207,11 @@ fun OnchainZapSendDialog( } } - val presetAmounts by accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices + // On-chain shares the single zap-amount list now; show only the entries + // that clear the on-chain minimum as quick presets. + val zapAmountChoices by accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices .collectAsStateWithLifecycle() + val presetAmounts = remember(zapAmountChoices) { zapAmountChoices.filter { it >= MIN_ONCHAIN_ZAP_SATS } } // Mirror the dropdown's NIP-05 / Namecoin (.bit) resolution so Send can // enable as soon as the typed name resolves, without forcing the user to diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2df3e835a9..e8c5e56fb8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -869,10 +869,7 @@ Not connected Advanced: enter connection details manually Quick Zap Amounts - Shown when pressing the zap button. Tap an amount to remove it. If you leave it empty, it will open the dialog to insert an amount every time. - Quick On-chain Zap Amounts - On-chain zaps pay miner fees, so amounts are usually larger than Lightning. Tap an amount to remove it. - New on-chain amount in sats + Shown when pressing the zap button. Each amount can be paid by any rail the recipient supports — Lightning, Cashu, or on-chain (on-chain only for larger amounts). Tap an amount to remove it. If you leave it empty, it will open the dialog to insert an amount every time. Send on-chain instead Zap Privacy Controls how your identity is shown when you send a zap. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ZapAmountMergeTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ZapAmountMergeTest.kt new file mode 100644 index 0000000000..b0b07fa635 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ZapAmountMergeTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The on-chain rail's separate amount-preset list was folded into the single + * [AccountZapPreferencesInternal.zapAmountChoices]. These lock in that the + * migration union preserves amounts from both the legacy lists and round-trips + * idempotently (write the on-chain subset back, union it again → same set). + */ +class ZapAmountMergeTest { + private fun zaps( + zap: List, + onchain: List, + ) = AccountZapPreferencesInternal(zapAmountChoices = zap, onchainZapAmountChoices = onchain) + + @Test + fun unionsDefaultsSortedAndDeduped() { + assertEquals( + listOf(21L, 50L, 100L, 5_000L), + mergeZapAmounts(zaps(DefaultZapAmounts, DefaultOnchainZapAmounts)), + ) + } + + @Test + fun preservesCustomAmountsFromBothLists() { + // Simulates a sync from an older client that still split the two lists. + assertEquals( + listOf(10L, 21L, 100L, 1_000L, 7_777L), + mergeZapAmounts(zaps(zap = listOf(21L, 100L, 10L), onchain = listOf(7_777L, 1_000L))), + ) + } + + @Test + fun dedupesAcrossLists() { + assertEquals( + listOf(100L, 5_000L), + mergeZapAmounts(zaps(zap = listOf(100L, 5_000L), onchain = listOf(5_000L))), + ) + } + + @Test + fun roundTripIsIdempotent() { + val merged = mergeZapAmounts(zaps(DefaultZapAmounts, DefaultOnchainZapAmounts)) + // toInternal() writes the full list as zap and the on-chain-eligible + // subset as the legacy field; merging those again must not drift. + val onchainSubset = merged.filter { it >= MIN_ONCHAIN_ZAP_SATS } + assertEquals(merged, mergeZapAmounts(zaps(zap = merged, onchain = onchainSubset))) + } +} From c73e7924dd62d20c9b87c80ad32d1feb00f3cfc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 19:19:33 +0000 Subject: [PATCH 03/27] feat(cashu): add mint-balance queries and mint-to-mint rebalance orchestration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the Reload Mint screen (NEEDS_RELOAD funding path): - peekMintBalances(): per-mint spendable balance from in-memory proofs (synchronous; feeds the balances list and source-feasibility checks). - recipientSharedMints(): the recipient's accepted mints we also hold — the candidate reload destinations. - rebalance(source, target, sats): mints a fresh quote at the target and pays its invoice by melting at the source, moving cashu between mints with no new sats. Verifies the source covers amount + fee before spending, abandons the unpaid quote otherwise, and leaves a resumable kind:7374 if the target hasn't confirmed yet — money is never lost on failure. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../model/nip60Cashu/CashuWalletState.kt | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 567cb11595..6cc59d458e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -812,6 +813,29 @@ class CashuWalletState( ) } + /** + * Spendable cashu balance per mint URL, summed from in-memory proofs. + * Synchronous and local — feeds the Reload Mint screen's balances list and + * source-feasibility checks. May overstate by NUT-07 stale proofs; the + * actual move scrubs before melting. + */ + fun peekMintBalances(): Map = + _tokenEntries.value + .groupBy { it.content.mint } + .mapValues { (_, entries) -> entries.sumOf { it.content.totalAmount() } } + + /** + * Mints the recipient accepts (their kind:10019) that we also hold a wallet + * at — the candidate destinations for a reload. Empty when the recipient + * has no kind:10019 or shares no mint with us. + */ + fun recipientSharedMints(recipientPubKey: HexKey): List { + val ourMints = _mints.value.toSet() + if (ourMints.isEmpty()) return emptyList() + val info = cache.getOrCreateUser(recipientPubKey).nutzapInfo() ?: return emptyList() + return info.mints().map { it.mintUrl }.filter { it in ourMints } + } + /** * NUT-09 wallet restore — recover proofs minted at [mintUrl] whose * kind:7375 token events have been lost. Scans deterministic @@ -1135,6 +1159,85 @@ class CashuWalletState( return ops.meltToLightning(mintUrl, quote, available) } + /** + * Move [sats] of cashu from [sourceMintUrl] into [targetMintUrl] by minting + * a fresh quote at the target and paying its invoice with a melt at the + * source — no new sats enter the wallet. Used by the Reload Mint screen to + * top up a recipient's mint from another mint the user already holds. + * + * The melt settles the Lightning payment synchronously, so the target quote + * should read PAID within moments; we poll briefly to absorb mint-side lag + * before issuing the proofs. + * + * Money is never lost on failure: a failed melt returns change to the + * source, and a paid-but-not-yet-issued target leaves a resumable kind:7374 + * the pending-quote banner can finish later. Throws with a user-facing + * message on any failure; callers surface it via [describeMintError]. + */ + suspend fun rebalance( + sourceMintUrl: String, + targetMintUrl: String, + sats: Long, + onProgress: ((Float) -> Unit)? = null, + ): RebalanceCompleted { + check(started) { "CashuWalletState.start() not called" } + require(sats > 0) { "Amount must be positive" } + require(sourceMintUrl != targetMintUrl) { "Source and target mints must differ" } + + // 1. Mint quote at the destination — publishes a resumable kind:7374. + onProgress?.invoke(0.1f) + val mintFlow = ops.startMintFromLightning(targetMintUrl, sats) + + // 2. Quote the melt at the source and make sure it covers amount + fee + // BEFORE spending. Abandon the unpaid mint quote otherwise so it + // doesn't linger in the pending banner. + onProgress?.invoke(0.25f) + val meltQuote = ops.requestMeltQuote(sourceMintUrl, mintFlow.invoice) + scrubLocallyStaleProofs(sourceMintUrl) + val sourceBalance = + _tokenEntries.value + .filter { it.content.mint == sourceMintUrl } + .sumOf { it.content.totalAmount() } + val required = meltQuote.amount + meltQuote.feeReserve + if (sourceBalance < required) { + runCatching { ops.cancelMintQuote(mintFlow.quoteEvent) } + throw IllegalStateException("$sourceMintUrl has $sourceBalance sat — needs $required to move $sats") + } + + // 3. Pay the destination invoice by melting at the source. + onProgress?.invoke(0.5f) + meltToLightning(sourceMintUrl, meltQuote) + + // 4. The melt paid the invoice; confirm + issue proofs at the target. + onProgress?.invoke(0.75f) + val pollAttempts = 8 + val pollDelayMs = 1_000L + var paid = false + var attempt = 0 + while (!paid && attempt < pollAttempts) { + val status = ops.checkMintQuote(targetMintUrl, mintFlow.mintQuote.quote) + paid = status.paid == true || status.state == "PAID" || status.state == "ISSUED" + if (!paid) { + delay(pollDelayMs * (attempt + 1)) + attempt++ + } + } + if (!paid) { + // Funds already left the source; the destination invoice is paid or + // will be. Leave the kind:7374 so the pending banner can finish it. + throw IllegalStateException("Paid $targetMintUrl but it hasn't confirmed yet — finish from the pending quote banner") + } + ops.completeMintFromLightning(targetMintUrl, mintFlow.quoteEvent, sats) + onProgress?.invoke(1.0f) + + return RebalanceCompleted( + sourceMintUrl = sourceMintUrl, + targetMintUrl = targetMintUrl, + movedSats = sats, + feeSats = meltQuote.feeReserve, + ) + } + // ============================================================ // Publish bridge // ============================================================ @@ -1179,3 +1282,11 @@ data class NutzapFunding( val bestSingleMintSats: Long, val totalWalletSats: Long, ) + +/** Result of a [CashuWalletState.rebalance] mint-to-mint move. */ +data class RebalanceCompleted( + val sourceMintUrl: String, + val targetMintUrl: String, + val movedSats: Long, + val feeSats: Long, +) From ce32dc4fc35beff5e80985115fccd0f5c499406c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 19:32:30 +0000 Subject: [PATCH 04/27] feat(zap): Reload Mint screen to fund a recipient's mint, then nutzap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the cashu logo on an amount chip is NEEDS_RELOAD (you hold enough cashu, but not in the recipient's mint), it now shows a dimmed logo with a "+" badge that opens a full Reload Mint screen instead of silently falling back to Lightning. The screen tells the whole story and auto-sends the nutzap on success: - Destination ("mint to reload") with its shortfall; selectable when the recipient accepts several mints we hold. - All cashu balances for context. - Source ("funds from"): another mint (mint-to-mint rebalance, no new sats) or Lightning. Mint rows are disabled when they can't cover shortfall + fee. Funding paths: - Rebalance: CashuWalletState.rebalance moves cashu between mints. - Lightning: mint a fresh quote at the destination, auto-pay via NWC when a wallet is configured (else show the invoice to copy), poll, then issue. Wiring: new Route.ReloadMint + AppNavigation entry; a ReloadMintRequest handed off via an LruCache on AccountViewModel (mirrors the manual-zap pattern); onReloadNutzap threaded through the zap popup overloads and the chip; callers (ReusableZapButton, ReactionsRow, NestActionBar) navigate to the screen. IMPOSSIBLE (not enough cashu anywhere) keeps falling back to Lightning — the reload screen is only for moving existing cashu. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../ui/components/ReusableZapButton.kt | 5 + .../amethyst/ui/navigation/AppNavigation.kt | 3 + .../amethyst/ui/navigation/routes/Routes.kt | 5 + .../amethyst/ui/note/ReactionsRow.kt | 61 +++- .../ui/screen/loggedIn/AccountViewModel.kt | 4 + .../nests/room/screen/NestActionBar.kt | 5 + .../loggedIn/wallet/ReloadMintScreen.kt | 336 ++++++++++++++++++ .../loggedIn/wallet/ReloadMintViewModel.kt | 280 +++++++++++++++ amethyst/src/main/res/values/strings.xml | 14 + 9 files changed, 705 insertions(+), 8 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt index a5757930c9..e392236e93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.note.ZappedIcon import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.navigateToReloadMint import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp import com.vitorpamplona.amethyst.ui.theme.Size14Modifier @@ -201,6 +202,10 @@ fun ReusableZapButton( onchainZapAmount = amount showOnchainDialog = true }, + onReloadNutzap = { amount -> + wantsToZap = null + navigateToReloadMint(accountViewModel, nav, baseNote, amount) + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index e06be196ac..f7b8ac0192 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -199,6 +199,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddWalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainTransactionsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.ReloadMintScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletDetailScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen @@ -343,6 +344,8 @@ fun BuildNavigation( composableFromBottomArgs { PayViaIntentScreen(it.paymentId, accountViewModel, nav) } + composableFromBottomArgs { ReloadMintScreen(it.requestId, accountViewModel, nav) } + composableFromBottomArgs { NewUserMetadataScreen(nav, accountViewModel) } composable { SearchScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b319573c8e..af2d44aa01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -639,6 +639,11 @@ sealed class Route { data class ManualZapSplitPayment( val paymentId: String, ) : Route() + + @Serializable + data class ReloadMint( + val requestId: String, + ) : Route() } inline fun isBaseRoute(navController: NavHostController): Boolean = navController.currentBackStackEntry?.destination?.hasRoute() == true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 681b3f836f..a5f0c19f0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -85,6 +85,7 @@ import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterStart import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap @@ -146,6 +147,7 @@ import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.PaymentTargetsDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.navigateToReloadMint import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.ButtonBorder @@ -1257,6 +1259,10 @@ fun ZapReaction( nav.nav(Route.ManualZapSplitPayment(uid)) } }, + onReloadNutzap = { amount -> + wantsToZap = false + navigateToReloadMint(accountViewModel, nav, baseNote, amount) + }, ) } @@ -1885,6 +1891,7 @@ fun ZapAmountChoicePopup( onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, onOnchainAmount: ((Long?) -> Unit)? = null, + onReloadNutzap: (Long) -> Unit = {}, ) { val zapAmountChoices by accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices @@ -1903,6 +1910,7 @@ fun ZapAmountChoicePopup( onPayViaIntent = onPayViaIntent, onOnchainAmount = onOnchainAmount ?: {}, onchainSupported = onOnchainAmount != null, + onReloadNutzap = onReloadNutzap, ) } @@ -1920,6 +1928,7 @@ fun ZapAmountChoicePopup( onPayViaIntent: (ImmutableList) -> Unit, onOnchainAmount: (Long?) -> Unit = {}, onchainSupported: Boolean = true, + onReloadNutzap: (Long) -> Unit = {}, ) { // One chip per amount; the chip itself shows which rails can pay it. // [zapAmountChoices] is already the single merged+sorted preset list (the @@ -1955,6 +1964,7 @@ fun ZapAmountChoicePopup( onError, onProgress, onPayViaIntent, + onReloadNutzap, ) } @@ -1973,6 +1983,7 @@ fun ZapAmountChoicePopup( onError: (title: String, text: String, user: User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, + onReloadNutzap: (Long) -> Unit = {}, ) { val context = LocalContext.current val yOffset = with(LocalDensity.current) { -popupYOffset.toPx().toInt() } @@ -2025,6 +2036,10 @@ fun ZapAmountChoicePopup( onOnchainAmount(amount) visibilityState.targetState = false }, + onReloadNutzap = { amount -> + onReloadNutzap(amount) + visibilityState.targetState = false + }, onChangeAmount = onChangeAmount, ) } @@ -2040,6 +2055,7 @@ fun ZapAmountChoicePopupContent( onNutzap: (Long) -> Unit, onOnchainAmount: (Long?) -> Unit, onChangeAmount: () -> Unit, + onReloadNutzap: (Long) -> Unit = {}, ) { Box(HalfPadding, contentAlignment = Center) { ElevatedCard( @@ -2060,6 +2076,7 @@ fun ZapAmountChoicePopupContent( onLightningZap = onLightningZap, onNutzap = onNutzap, onOnchainAmount = onOnchainAmount, + onReloadNutzap = onReloadNutzap, onChangeAmount = onChangeAmount, ) } @@ -2085,17 +2102,19 @@ fun ZapAmountChoicePopupContent( /** * One pill per amount, showing a tappable logo for every rail that can pay it: - * - **Cashu** only when a single shared mint is already funded for the amount - * (the one rail whose balance is free to read). Underfunded/reload states - * are intentionally not offered yet. + * - **Cashu** as a solid logo when a single shared mint already covers the + * amount (FUNDED), or as a dimmed logo with a "+" badge when funds exist but + * in the wrong mint (NEEDS_RELOAD) — tapping the latter opens the Reload Mint + * screen. Cashu is the one rail whose balance is free to read. * - **Lightning** optimistically whenever the recipient can receive — an * external wallet can pay any invoice, so we don't gate on a sender balance. * - **On-chain** when a backend is configured and the amount clears * [MIN_ONCHAIN_ZAP_SATS]; UTXO sufficiency stays a send-time check. * - * Tapping the amount fires the cheapest/fastest available rail (cashu → - * Lightning → on-chain); tapping a specific logo forces that rail. Long-press - * opens the amount-preset editor. A pill with no payable rail is not rendered. + * Tapping the amount fires the cheapest/fastest available rail (cashu funded → + * Lightning → cashu reload → on-chain); tapping a specific logo forces that + * rail. Long-press opens the amount-preset editor. A pill with no payable rail + * is not rendered. */ @OptIn(ExperimentalFoundationApi::class) @Composable @@ -2105,13 +2124,16 @@ private fun UnifiedZapAmountChip( onLightningZap: (Long) -> Unit, onNutzap: (Long) -> Unit, onOnchainAmount: (Long?) -> Unit, + onReloadNutzap: (Long) -> Unit, onChangeAmount: () -> Unit, ) { - val cashuReady = railCapability.cashuStatus(amountInSats) == CashuRailStatus.FUNDED + val cashuStatus = railCapability.cashuStatus(amountInSats) + val cashuReady = cashuStatus == CashuRailStatus.FUNDED + val cashuReloadable = cashuStatus == CashuRailStatus.NEEDS_RELOAD val lightningReady = railCapability.hasLightning val onchainReady = railCapability.hasOnchain && amountInSats >= MIN_ONCHAIN_ZAP_SATS - if (!cashuReady && !lightningReady && !onchainReady) return + if (!cashuReady && !cashuReloadable && !lightningReady && !onchainReady) return val defaultAction: () -> Unit = when { @@ -2121,6 +2143,9 @@ private fun UnifiedZapAmountChip( lightningReady -> { { onLightningZap(amountInSats) } } + cashuReloadable -> { + { onReloadNutzap(amountInSats) } + } else -> { { onOnchainAmount(amountInSats) } } @@ -2157,6 +2182,26 @@ private fun UnifiedZapAmountChip( ) } } + if (cashuReloadable) { + // Funds exist but in the wrong mint — a dimmed cashu logo with a + // "+" badge; tap opens the Reload Mint screen to top it up first. + RailLogo(onClick = { onReloadNutzap(amountInSats) }) { + Row(verticalAlignment = CenterVertically) { + Material3Icon( + imageVector = CustomHashTagIcons.Cashu, + contentDescription = stringRes(R.string.reload_mint_title), + modifier = Size18Modifier.alpha(0.5f), + tint = Color.Unspecified, + ) + Icon( + symbol = MaterialSymbols.AddCircle, + contentDescription = null, + modifier = Size18Modifier, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } if (lightningReady) { RailLogo(onClick = { onLightningZap(amountInSats) }) { Icon( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 3f034ebac0..ecaf7fb398 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.screen.UiSettingsState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NOTIFICATION_LAST_READ_KEY import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.ReloadMintRequest import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId @@ -320,6 +321,9 @@ class AccountViewModel( val tempManualPaymentCache = LruCache>(5) + /** Hand-off for the Reload Mint screen — keyed by a uid put on the back stack. */ + val tempReloadRequestCache = LruCache(5) + @OptIn(ExperimentalCoroutinesApi::class) val notificationHasNewItems = // When split-notifications is on, the badge tracks only the Following feed. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt index 623c717a85..274b9b3cae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt @@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.note.zapClick import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.reactions.RoomReactionPopup +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.navigateToReloadMint import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -565,6 +566,10 @@ private fun NestZapButton( nav.nav(Route.ManualZapSplitPayment(uid)) } }, + onReloadNutzap = { amount -> + wantsToZap = false + navigateToReloadMint(accountViewModel, nav, roomNote, amount) + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt new file mode 100644 index 0000000000..28dbe4affe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.showAmount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import java.util.UUID + +/** + * Stash the pending nutzap and open the Reload Mint screen. Called from the zap + * picker when the user taps an underfunded (NEEDS_RELOAD) cashu logo. + */ +fun navigateToReloadMint( + accountViewModel: AccountViewModel, + nav: INav, + baseNote: Note, + amountSats: Long, +) { + val uid = UUID.randomUUID().toString() + accountViewModel.tempReloadRequestCache.put(uid, ReloadMintRequest(baseNote, amountSats)) + nav.nav(Route.ReloadMint(uid)) +} + +private fun shortMint(url: String): String = url.removePrefix("https://").removePrefix("http://").removeSuffix("/") + +private fun sats(value: Long): String = showAmount(value.toBigDecimal().setScale(1)) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReloadMintScreen( + requestId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val request = remember(requestId) { accountViewModel.tempReloadRequestCache.get(requestId) } + + // The cache entry can be gone after process death — nothing to fund, pop. + if (request == null) { + LaunchedEffect(Unit) { nav.popBack() } + return + } + + val viewModel: ReloadMintViewModel = viewModel() + LaunchedEffect(requestId) { viewModel.init(accountViewModel, request) } + + val ui by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + if (ui.status is ReloadStatus.Done) { + LaunchedEffect(Unit) { nav.popBack() } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.reload_mint_title)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + text = stringRes(R.string.reload_mint_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + + // ── Destination ─────────────────────────────────────────────── + SectionHeader(stringRes(R.string.reload_mint_section_to)) + if (ui.targetOptions.size > 1) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ui.targetOptions.forEach { mint -> + FilterChip( + selected = mint == ui.selectedTarget, + onClick = { viewModel.selectTarget(mint) }, + label = { Text(shortMint(mint), maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ) + } + } + } + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = shortMint(ui.selectedTarget), + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = stringRes(R.string.reload_mint_needs_more, sats(ui.shortfallSats)), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + // ── All balances ────────────────────────────────────────────── + SectionHeader(stringRes(R.string.reload_mint_section_balances)) + ui.balances.forEach { bal -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = shortMint(bal.mintUrl), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = + if (bal.mintUrl == ui.selectedTarget) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.weight(1f), + ) + Text(text = "${sats(bal.balanceSats)} sat", color = MaterialTheme.colorScheme.placeholderText) + } + } + + // ── Source ──────────────────────────────────────────────────── + SectionHeader(stringRes(R.string.reload_mint_section_from)) + ui.sources.forEach { source -> + SourceRow( + source = source, + selected = source == ui.selectedSource, + onSelect = { viewModel.selectSource(source) }, + ) + } + + Spacer(Modifier.height(4.dp)) + + // ── Summary + action ────────────────────────────────────────── + Text( + text = stringRes(R.string.reload_mint_move_summary, sats(ui.shortfallSats), sats(ui.estFeeSats)), + style = MaterialTheme.typography.bodyMedium, + ) + + when (val status = ui.status) { + is ReloadStatus.Working -> { + LinearProgressIndicator(progress = { status.progress }, modifier = Modifier.fillMaxWidth()) + Text(status.step, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + } + + is ReloadStatus.AwaitingInvoice -> { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + CircularProgressIndicator(modifier = Modifier.height(20.dp)) + Text(stringRes(R.string.reload_mint_awaiting_payment), style = MaterialTheme.typography.bodyMedium) + } + OutlinedButton( + onClick = { copyToClipboard(context, status.invoice) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(R.string.reload_mint_copy_invoice)) + } + } + + is ReloadStatus.Failed -> { + Text(status.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) + Button(onClick = { viewModel.confirm() }, modifier = Modifier.fillMaxWidth()) { + Text(stringRes(R.string.reload_mint_retry)) + } + } + + else -> { + val source = ui.selectedSource + val enabled = source is ReloadSource.Lightning || (source is ReloadSource.Mint && source.canCover) + Button( + onClick = { viewModel.confirm() }, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(R.string.reload_mint_confirm)) + } + } + } + } + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + ) +} + +@Composable +private fun SourceRow( + source: ReloadSource, + selected: Boolean, + onSelect: () -> Unit, +) { + val enabled = source is ReloadSource.Lightning || (source is ReloadSource.Mint && source.canCover) + val title = + when (source) { + is ReloadSource.Mint -> shortMint(source.mintUrl) + ReloadSource.Lightning -> stringRes(R.string.reload_mint_pay_lightning) + } + val trailing = + when (source) { + is ReloadSource.Mint -> + if (source.canCover) "${sats(source.balanceSats)} sat" else stringRes(R.string.reload_mint_not_enough) + ReloadSource.Lightning -> stringRes(R.string.reload_mint_lightning_desc) + } + + Card( + colors = + CardDefaults.cardColors( + containerColor = + if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + modifier = + Modifier + .fillMaxWidth() + .selectable(selected = selected, enabled = enabled, onClick = onSelect), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = trailing, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } +} + +private fun copyToClipboard( + context: Context, + text: String, +) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("invoice", text)) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt new file mode 100644 index 0000000000..43d44b3d35 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState +import com.vitorpamplona.amethyst.model.nip60Cashu.describeMintError +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** Hand-off payload for the Reload Mint screen — the pending nutzap to fund. */ +data class ReloadMintRequest( + val baseNote: Note, + val amountSats: Long, +) + +/** Where the reload funds come from. */ +@Immutable +sealed interface ReloadSource { + /** Move existing cashu from another mint we hold (no new sats). */ + data class Mint( + val mintUrl: String, + val balanceSats: Long, + val canCover: Boolean, + ) : ReloadSource + + /** Pay a fresh Lightning invoice to mint at the destination. */ + data object Lightning : ReloadSource +} + +/** A mint we hold a balance at, for the balances list. */ +@Immutable +data class MintBalance( + val mintUrl: String, + val balanceSats: Long, +) + +/** Progress of the reload + send. */ +@Immutable +sealed interface ReloadStatus { + data object Configuring : ReloadStatus + + data class Working( + val step: String, + val progress: Float, + ) : ReloadStatus + + /** No NWC wallet — the user must pay [invoice] externally; we keep polling. */ + data class AwaitingInvoice( + val invoice: String, + val sats: Long, + ) : ReloadStatus + + data object Done : ReloadStatus + + data class Failed( + val message: String, + ) : ReloadStatus +} + +@Immutable +data class ReloadUiState( + val recipient: String = "", + val amountSats: Long = 0L, + val balances: ImmutableList = persistentListOf(), + val targetOptions: ImmutableList = persistentListOf(), + val selectedTarget: String = "", + val shortfallSats: Long = 0L, + val estFeeSats: Long = 0L, + val sources: ImmutableList = persistentListOf(), + val selectedSource: ReloadSource? = null, + val status: ReloadStatus = ReloadStatus.Configuring, +) + +class ReloadMintViewModel : ViewModel() { + private var accountViewModel: AccountViewModel? = null + private var baseNote: Note? = null + private val state: CashuWalletState? get() = accountViewModel?.account?.cashuWalletState + + private val _uiState = MutableStateFlow(ReloadUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun init( + accountViewModel: AccountViewModel, + request: ReloadMintRequest, + ) { + this.accountViewModel = accountViewModel + this.baseNote = request.baseNote + val st = state ?: return + val recipient = request.baseNote.author?.pubkeyHex ?: return + + val balances = st.peekMintBalances() + val targets = + st + .recipientSharedMints(recipient) + .sortedByDescending { balances[it] ?: 0L } + val defaultTarget = targets.firstOrNull() ?: return + + _uiState.value = + ReloadUiState( + recipient = recipient, + amountSats = request.amountSats, + balances = + balances.entries + .sortedByDescending { it.value } + .map { MintBalance(it.key, it.value) } + .toImmutableList(), + targetOptions = targets.toImmutableList(), + selectedTarget = defaultTarget, + ).let { recomputeFor(it, defaultTarget) } + } + + /** Recompute shortfall, fee estimate, sources, and a default source. */ + private fun recomputeFor( + base: ReloadUiState, + target: String, + ): ReloadUiState { + val balances = base.balances.associate { it.mintUrl to it.balanceSats } + val targetBalance = balances[target] ?: 0L + val shortfall = (base.amountSats - targetBalance).coerceAtLeast(0L) + // Rough fee cushion for enabling a source; the melt quote sets the real + // fee at execution time. 1% (min 1 sat). + val estFee = (shortfall / 100L).coerceAtLeast(1L) + + val mintSources = + base.balances + .filter { it.mintUrl != target } + .map { ReloadSource.Mint(it.mintUrl, it.balanceSats, canCover = it.balanceSats >= shortfall + estFee) } + + val sources: List = mintSources + ReloadSource.Lightning + // Prefer the richest mint that can cover (no new sats); else Lightning. + val defaultSource: ReloadSource = + mintSources.filter { it.canCover }.maxByOrNull { it.balanceSats } ?: ReloadSource.Lightning + + return base.copy( + selectedTarget = target, + shortfallSats = shortfall, + estFeeSats = estFee, + sources = sources.toImmutableList(), + selectedSource = defaultSource, + ) + } + + fun selectTarget(mintUrl: String) { + _uiState.update { recomputeFor(it, mintUrl) } + } + + fun selectSource(source: ReloadSource) { + _uiState.update { it.copy(selectedSource = source) } + } + + fun confirm() { + val vm = accountViewModel ?: return + val note = baseNote ?: return + val s = _uiState.value + val source = s.selectedSource ?: return + + vm.launchSigner { + try { + when (source) { + is ReloadSource.Mint -> rebalanceThenZap(source.mintUrl, s.selectedTarget, s.shortfallSats, note, s.amountSats) + ReloadSource.Lightning -> reloadFromLightningThenZap(s.selectedTarget, s.shortfallSats, note, s.amountSats) + } + } catch (e: Exception) { + setStatus(ReloadStatus.Failed(describeMintError(e))) + } + } + } + + private suspend fun rebalanceThenZap( + sourceMint: String, + targetMint: String, + shortfall: Long, + note: Note, + amount: Long, + ) { + val st = state ?: return + setStatus(ReloadStatus.Working("Moving funds", 0.1f)) + st.rebalance(sourceMint, targetMint, shortfall) { p -> + setStatus(ReloadStatus.Working("Moving funds", p.coerceIn(0.1f, 0.9f))) + } + sendNutzapAndFinish(note, amount) + } + + private suspend fun reloadFromLightningThenZap( + targetMint: String, + shortfall: Long, + note: Note, + amount: Long, + ) { + val vm = accountViewModel ?: return + val st = state ?: return + val ops = st.ops + + setStatus(ReloadStatus.Working("Requesting invoice", 0.1f)) + val flow = ops.startMintFromLightning(targetMint, shortfall) + + val walletUri = vm.account.settings.defaultZapPaymentRequest() + if (walletUri != null) { + setStatus(ReloadStatus.Working("Paying from your wallet", 0.35f)) + // Fire-and-forget: the mint-quote poll below is the source of truth + // for whether the payment actually landed. + runCatching { + vm.account.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { } + } + } else { + // No NWC — surface the invoice for an external wallet and keep polling. + setStatus(ReloadStatus.AwaitingInvoice(flow.invoice, shortfall)) + } + + val attempts = 90 + val delayMs = 2_000L + var paid = false + var attempt = 0 + while (!paid && attempt < attempts) { + val status = ops.checkMintQuote(targetMint, flow.mintQuote.quote) + paid = status.paid == true || status.state == "PAID" || status.state == "ISSUED" + if (!paid) { + delay(delayMs) + attempt++ + } + } + if (!paid) { + setStatus(ReloadStatus.Failed("Invoice not paid yet — you can finish it later from the pending quote banner")) + return + } + setStatus(ReloadStatus.Working("Issuing ecash", 0.85f)) + ops.completeMintFromLightning(targetMint, flow.quoteEvent, shortfall) + sendNutzapAndFinish(note, amount) + } + + private fun sendNutzapAndFinish( + note: Note, + amount: Long, + ) { + val vm = accountViewModel ?: return + setStatus(ReloadStatus.Working("Sending zap", 0.95f)) + // The destination is now funded; fire the nutzap (its own error path + // toasts) and report Done so the screen can pop. + vm.sendNutzap( + baseNote = note, + amountSats = amount, + message = "", + onError = { _, msg, _ -> setStatus(ReloadStatus.Failed(msg)) }, + ) + setStatus(ReloadStatus.Done) + } + + private fun setStatus(status: ReloadStatus) { + _uiState.update { it.copy(status = status) } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e8c5e56fb8..9f8b9292e1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -871,6 +871,20 @@ Quick Zap Amounts Shown when pressing the zap button. Each amount can be paid by any rail the recipient supports — Lightning, Cashu, or on-chain (on-chain only for larger amounts). Tap an amount to remove it. If you leave it empty, it will open the dialog to insert an amount every time. Send on-chain instead + Reload Mint + Top up the recipient\'s mint so you can send this nutzap in ecash. + Mint to reload + Your balances + Funds from + Pay with Lightning + Mint new ecash from your Lightning wallet + Not enough here + Move funds & send zap + Waiting for payment… + Try again + Move %1$s sat · fee ≈ %2$s sat + needs %1$s sat more + Copy invoice Zap Privacy Controls how your identity is shown when you send a zap. Connect Wallet From b1961ea19cd2bb72e347b565964ae4b26360fb92 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 20:23:00 +0000 Subject: [PATCH 05/27] fix(zap): address reload-mint audit findings (double-submit, premature done, fee/poll robustness) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - ReloadMintViewModel.confirm() now guards against re-entry (only starts from Configuring/Failed and flips to Working synchronously), so a double-tap or a Failed-state Retry can't launch two pipelines that double-spend the source / double-mint the target. - sendNutzapAndFinish awaits the real CashuWalletState.sendNutzap (throws on failure) and reports Done only on success — a send that fails after a successful reload now surfaces as Failed instead of popping the screen on a premature "done" and silently stranding the moved funds. - The reload pipeline runs on the long-lived AccountViewModel scope; the VM now holds its Job and cancels it in onCleared(), so leaving the screen stops the (up to 3-minute) Lightning poll instead of hammering the mint unobserved. - rebalance() poll budget widened to a steady ~60s so a merely-slow mint no longer strands funds that already left the source. - Mint a small headroom buffer (RELOAD_FEE_BUFFER_SATS) above the bare shortfall so the follow-up nutzap's own swap fee doesn't leave the target a sat short; the source-feasibility gate accounts for it. Regressions from the settings merge: - mergeZapAmounts / the picker no longer .sorted() the amounts — a user's saved preset order is preserved instead of being silently reordered ascending. - The on-chain send dialog falls back to DEFAULT_ONCHAIN_ZAP_SATS when the unified list has nothing above the on-chain minimum, restoring the guaranteed quick-pick preset. - zapClick's one-tap (single-amount) path now checks rail capability and opens the picker when the recipient can't receive Lightning, instead of firing a doomed Lightning zap. Cleanup: - Centralized the mint-quote "settled" predicate as MintQuoteBolt11ResponseDto .isSettled(); rebalance, ReloadMintViewModel and CashuWalletViewModel now share it instead of three copies of paid==true || PAID || ISSUED. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../amethyst/model/AccountSyncedSettings.kt | 2 +- .../model/AccountSyncedSettingsInternal.kt | 7 ++ .../model/nip60Cashu/CashuWalletState.kt | 13 ++- .../amethyst/ui/note/ReactionsRow.kt | 35 ++++-- .../loggedIn/wallet/CashuWalletViewModel.kt | 4 +- .../loggedIn/wallet/OnchainZapSendDialog.kt | 9 +- .../loggedIn/wallet/ReloadMintViewModel.kt | 109 +++++++++++++----- .../quartz/nip60Cashu/mintApi/MintApiDtos.kt | 10 +- 8 files changed, 140 insertions(+), 49 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index e5f6367fe7..f10791a1d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -189,7 +189,7 @@ class AccountZapPreferences( * legacy `onchainZapAmountChoices`. Idempotent: [AccountSyncedSettings.toInternal] * re-derives the on-chain field as a subset of this list. */ -internal fun mergeZapAmounts(zaps: AccountZapPreferencesInternal): List = (zaps.zapAmountChoices + zaps.onchainZapAmountChoices).distinct().sorted() +internal fun mergeZapAmounts(zaps: AccountZapPreferencesInternal): List = (zaps.zapAmountChoices + zaps.onchainZapAmountChoices).distinct() @Stable class AccountLanguagePreferences( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index 8b8aa10b2b..bdc1ab134e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -51,6 +51,13 @@ val DefaultReportWarningThreshold = 5 */ const val MIN_ONCHAIN_ZAP_SATS = 1_000L +/** + * Default on-chain quick-pick amount, used when the user's unified zap-amount + * list contains nothing at or above [MIN_ONCHAIN_ZAP_SATS]. Matches the legacy + * dedicated on-chain default so the send dialog always offers one preset. + */ +const val DEFAULT_ONCHAIN_ZAP_SATS = 5_000L + @Serializable enum class ReactionRowAction { Reply, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 6cc59d458e..9218037079 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -1209,16 +1209,19 @@ class CashuWalletState( meltToLightning(sourceMintUrl, meltQuote) // 4. The melt paid the invoice; confirm + issue proofs at the target. + // The melt settled synchronously, but a healthy mint can still lag a + // while before flipping the quote to PAID, so give it a generous + // ~60s steady budget rather than a tight escalating one — a merely + // slow mint shouldn't strand funds that have already left the source. onProgress?.invoke(0.75f) - val pollAttempts = 8 - val pollDelayMs = 1_000L + val pollAttempts = 30 + val pollDelayMs = 2_000L var paid = false var attempt = 0 while (!paid && attempt < pollAttempts) { - val status = ops.checkMintQuote(targetMintUrl, mintFlow.mintQuote.quote) - paid = status.paid == true || status.state == "PAID" || status.state == "ISSUED" + paid = ops.checkMintQuote(targetMintUrl, mintFlow.mintQuote.quote).isSettled() if (!paid) { - delay(pollDelayMs * (attempt + 1)) + delay(pollDelayMs) attempt++ } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index a5f0c19f0e..ece972c6f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -1387,17 +1387,26 @@ fun zapClick( } choices.size == 1 -> { - onZapStarts() - accountViewModel.zap( - baseNote, - choices.first() * 1000, - null, - "", - context, - onError = onError, - onProgress = { onZappingProgress(it) }, - onPayViaIntent = onPayViaIntent, - ) + // One-tap fast path is Lightning-only. If the recipient can't + // receive Lightning (no lud16/lud06), firing a zap here would just + // fail — open the picker instead so the rail-aware chip can route to + // cashu / on-chain / reload. + val caps = RailCapabilityResolver.peek(baseNote, accountViewModel.account.cashuWalletState) + if (caps.hasLightning) { + onZapStarts() + accountViewModel.zap( + baseNote, + choices.first() * 1000, + null, + "", + context, + onError = onError, + onProgress = { onZappingProgress(it) }, + onPayViaIntent = onPayViaIntent, + ) + } else { + onMultipleChoices() + } } choices.size > 1 -> { @@ -1948,7 +1957,9 @@ fun ZapAmountChoicePopup( } val amountChoices = remember(zapAmountChoices) { - zapAmountChoices.distinct().sorted().toImmutableList() + // Keep the user's saved order (already de-duped at the settings + // layer); don't re-sort, that would override a deliberate ordering. + zapAmountChoices.distinct().toImmutableList() } val visibilityState = rememberVisibilityState(onDismiss) ZapAmountChoicePopup( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt index bae29cdb97..63323e2f0a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt @@ -426,7 +426,7 @@ class CashuWalletViewModel : ViewModel() { vm.launchSigner { try { val status = ops.checkMintQuote(current.mintUrl, current.flow.mintQuote.quote) - val paid = status.paid == true || status.state == "PAID" || status.state == "ISSUED" + val paid = status.isSettled() if (!paid) { // Roll back to AwaitingPayment so the polling // LaunchedEffect picks up again on the next tick. @@ -480,7 +480,7 @@ class CashuWalletViewModel : ViewModel() { val amountSats = runCatching { LnInvoiceUtil.getAmountInSats(status.request).toLong() } .getOrElse { 0L } - val paid = status.paid == true || status.state == "PAID" || status.state == "ISSUED" + val paid = status.isSettled() if (paid && amountSats > 0) { ops.completeMintFromLightning(mintUrl, quoteEvent, amountSats) _mintState.value = CashuMintFlowState.Completed(amountSats) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt index 3693ab853f..79f6ab4651 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSplitter +import com.vitorpamplona.amethyst.model.DEFAULT_ONCHAIN_ZAP_SATS import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.MIN_ONCHAIN_ZAP_SATS import com.vitorpamplona.amethyst.model.User @@ -211,7 +212,13 @@ fun OnchainZapSendDialog( // that clear the on-chain minimum as quick presets. val zapAmountChoices by accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices .collectAsStateWithLifecycle() - val presetAmounts = remember(zapAmountChoices) { zapAmountChoices.filter { it >= MIN_ONCHAIN_ZAP_SATS } } + // Presets that clear the on-chain minimum. If the user's unified list has + // none (e.g. only small Lightning amounts), fall back to a sensible default + // so the dialog still offers a quick-pick chip instead of an empty row. + val presetAmounts = + remember(zapAmountChoices) { + zapAmountChoices.filter { it >= MIN_ONCHAIN_ZAP_SATS }.ifEmpty { listOf(DEFAULT_ONCHAIN_ZAP_SATS) } + } // Mirror the dropdown's NIP-05 / Namecoin (.bit) resolution so Send can // enable as soon as the typed name resolves, without forcing the user to diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt index 43d44b3d35..986e16c0b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt @@ -26,10 +26,13 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState import com.vitorpamplona.amethyst.model.nip60Cashu.describeMintError import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -105,6 +108,11 @@ class ReloadMintViewModel : ViewModel() { private var baseNote: Note? = null private val state: CashuWalletState? get() = accountViewModel?.account?.cashuWalletState + /** The in-flight reload pipeline, so a second tap can be rejected and the + * whole flow cancelled when the screen leaves (it runs on the long-lived + * AccountViewModel scope, not this VM's). */ + private var job: Job? = null + private val _uiState = MutableStateFlow(ReloadUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -146,14 +154,20 @@ class ReloadMintViewModel : ViewModel() { val balances = base.balances.associate { it.mintUrl to it.balanceSats } val targetBalance = balances[target] ?: 0L val shortfall = (base.amountSats - targetBalance).coerceAtLeast(0L) - // Rough fee cushion for enabling a source; the melt quote sets the real - // fee at execution time. 1% (min 1 sat). - val estFee = (shortfall / 100L).coerceAtLeast(1L) + // Rough fee cushion for *enabling* a source — the real Lightning + // feeReserve is only known once rebalance() fetches the melt quote, so + // this is a heuristic (1%, min 2 sat). A source that clears the estimate + // but not the real quote fails recoverably (Failed → pick another), it + // doesn't move funds. + val estFee = (shortfall / 100L).coerceAtLeast(2L) + // A source must cover what we actually mint (shortfall + headroom buffer) + // plus that melt fee. + val needFromSource = shortfall + RELOAD_FEE_BUFFER_SATS + estFee val mintSources = base.balances .filter { it.mintUrl != target } - .map { ReloadSource.Mint(it.mintUrl, it.balanceSats, canCover = it.balanceSats >= shortfall + estFee) } + .map { ReloadSource.Mint(it.mintUrl, it.balanceSats, canCover = it.balanceSats >= needFromSource) } val sources: List = mintSources + ReloadSource.Lightning // Prefer the richest mint that can cover (no new sats); else Lightning. @@ -183,28 +197,43 @@ class ReloadMintViewModel : ViewModel() { val s = _uiState.value val source = s.selectedSource ?: return - vm.launchSigner { - try { - when (source) { - is ReloadSource.Mint -> rebalanceThenZap(source.mintUrl, s.selectedTarget, s.shortfallSats, note, s.amountSats) - ReloadSource.Lightning -> reloadFromLightningThenZap(s.selectedTarget, s.shortfallSats, note, s.amountSats) + // In-flight guard: only start from a resting state. Without it a double + // tap (or the Failed-state Retry) launches a second pipeline — two mint + // quotes at the target and two melts at the source, double-spending / + // double-minting. Flip to Working synchronously so the second call bails. + if (s.status !is ReloadStatus.Configuring && s.status !is ReloadStatus.Failed) return + setStatus(ReloadStatus.Working("Starting", 0.05f)) + + // Mint a hair more than the bare shortfall so the follow-up nutzap's own + // swap fee doesn't leave the target a sat short (see #RELOAD_FEE_BUFFER). + val moveSats = s.shortfallSats + RELOAD_FEE_BUFFER_SATS + + job?.cancel() + job = + vm.launchSigner { + try { + when (source) { + is ReloadSource.Mint -> rebalanceThenZap(source.mintUrl, s.selectedTarget, moveSats, note, s.amountSats) + ReloadSource.Lightning -> reloadFromLightningThenZap(s.selectedTarget, moveSats, note, s.amountSats) + } + } catch (e: CancellationException) { + throw e // screen left mid-flow — don't mask as a Failed state + } catch (e: Exception) { + setStatus(ReloadStatus.Failed(describeMintError(e))) } - } catch (e: Exception) { - setStatus(ReloadStatus.Failed(describeMintError(e))) } - } } private suspend fun rebalanceThenZap( sourceMint: String, targetMint: String, - shortfall: Long, + moveSats: Long, note: Note, amount: Long, ) { val st = state ?: return setStatus(ReloadStatus.Working("Moving funds", 0.1f)) - st.rebalance(sourceMint, targetMint, shortfall) { p -> + st.rebalance(sourceMint, targetMint, moveSats) { p -> setStatus(ReloadStatus.Working("Moving funds", p.coerceIn(0.1f, 0.9f))) } sendNutzapAndFinish(note, amount) @@ -212,7 +241,7 @@ class ReloadMintViewModel : ViewModel() { private suspend fun reloadFromLightningThenZap( targetMint: String, - shortfall: Long, + moveSats: Long, note: Note, amount: Long, ) { @@ -221,7 +250,7 @@ class ReloadMintViewModel : ViewModel() { val ops = st.ops setStatus(ReloadStatus.Working("Requesting invoice", 0.1f)) - val flow = ops.startMintFromLightning(targetMint, shortfall) + val flow = ops.startMintFromLightning(targetMint, moveSats) val walletUri = vm.account.settings.defaultZapPaymentRequest() if (walletUri != null) { @@ -233,16 +262,18 @@ class ReloadMintViewModel : ViewModel() { } } else { // No NWC — surface the invoice for an external wallet and keep polling. - setStatus(ReloadStatus.AwaitingInvoice(flow.invoice, shortfall)) + setStatus(ReloadStatus.AwaitingInvoice(flow.invoice, moveSats)) } + // External payment can take a while; the poll runs on a job tied to the + // screen (cancelled in onCleared), so leaving stops it instead of + // hammering the mint for 3 minutes with nobody watching. val attempts = 90 val delayMs = 2_000L var paid = false var attempt = 0 while (!paid && attempt < attempts) { - val status = ops.checkMintQuote(targetMint, flow.mintQuote.quote) - paid = status.paid == true || status.state == "PAID" || status.state == "ISSUED" + paid = ops.checkMintQuote(targetMint, flow.mintQuote.quote).isSettled() if (!paid) { delay(delayMs) attempt++ @@ -253,23 +284,30 @@ class ReloadMintViewModel : ViewModel() { return } setStatus(ReloadStatus.Working("Issuing ecash", 0.85f)) - ops.completeMintFromLightning(targetMint, flow.quoteEvent, shortfall) + ops.completeMintFromLightning(targetMint, flow.quoteEvent, moveSats) sendNutzapAndFinish(note, amount) } - private fun sendNutzapAndFinish( + /** + * Send the nutzap and only report [ReloadStatus.Done] once it actually + * succeeds. Suspends on the real send (throwing on failure) instead of the + * fire-and-forget AccountViewModel.sendNutzap, so a send that fails after a + * successful reload surfaces as Failed here rather than silently stranding + * the just-moved funds while the screen pops on a premature "done". + */ + private suspend fun sendNutzapAndFinish( note: Note, amount: Long, ) { - val vm = accountViewModel ?: return + val st = state ?: return + val recipient = note.author?.pubkeyHex ?: throw IllegalStateException("Recipient has no pubkey") + val zappedEvent = note.toEventHint() ?: throw IllegalStateException("Nothing to zap") setStatus(ReloadStatus.Working("Sending zap", 0.95f)) - // The destination is now funded; fire the nutzap (its own error path - // toasts) and report Done so the screen can pop. - vm.sendNutzap( - baseNote = note, + st.sendNutzap( amountSats = amount, + recipientPubKey = recipient, + zappedEvent = zappedEvent, message = "", - onError = { _, msg, _ -> setStatus(ReloadStatus.Failed(msg)) }, ) setStatus(ReloadStatus.Done) } @@ -277,4 +315,21 @@ class ReloadMintViewModel : ViewModel() { private fun setStatus(status: ReloadStatus) { _uiState.update { it.copy(status = status) } } + + override fun onCleared() { + // The pipeline runs on the AccountViewModel scope, not this VM's, so it + // would outlive the screen — cancel it when the screen goes away. + job?.cancel() + super.onCleared() + } + + companion object { + /** + * Extra sats minted at the target beyond the bare shortfall, so the + * follow-up nutzap's own swap fee doesn't leave the mint a hair short. + * Most mints charge no input fee; this small cushion covers the ones + * that do without meaningfully over-minting. + */ + private const val RELOAD_FEE_BUFFER_SATS = 2L + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintApiDtos.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintApiDtos.kt index ff1f8929d6..1d451a5c23 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintApiDtos.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintApiDtos.kt @@ -192,7 +192,15 @@ data class MintQuoteBolt11ResponseDto( val state: String, val expiry: Long? = null, val paid: Boolean? = null, -) +) { + /** + * True once the mint considers the quote's invoice settled — either the + * legacy `paid` boolean (NUT-04 v0) or the `state` machine reaching PAID + * (invoice settled, proofs not yet issued) or ISSUED (proofs minted). + * Centralized so every poll loop agrees on what "paid" means. + */ + fun isSettled(): Boolean = paid == true || state == "PAID" || state == "ISSUED" +} @Serializable data class MintBolt11RequestDto( From 116ff33cd879b7db038f9905bdc63f2c169b92be Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 20:30:58 +0000 Subject: [PATCH 06/27] test(zap): assert merge preserves preset order instead of sorting Follow-up to dropping .sorted() from mergeZapAmounts: update ZapAmountMergeTest to lock in first-seen order (Lightning list, then on-chain extras) rather than ascending, so a user's saved ordering is provably preserved. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../amethyst/model/ZapAmountMergeTest.kt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ZapAmountMergeTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ZapAmountMergeTest.kt index b0b07fa635..88128d7053 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ZapAmountMergeTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ZapAmountMergeTest.kt @@ -26,8 +26,10 @@ import org.junit.Test /** * The on-chain rail's separate amount-preset list was folded into the single * [AccountZapPreferencesInternal.zapAmountChoices]. These lock in that the - * migration union preserves amounts from both the legacy lists and round-trips - * idempotently (write the on-chain subset back, union it again → same set). + * migration union preserves amounts from both the legacy lists, keeps the + * user's saved ordering (Lightning list first, then on-chain extras — NOT + * re-sorted), and round-trips idempotently (write the on-chain subset back, + * union it again → same list). */ class ZapAmountMergeTest { private fun zaps( @@ -36,7 +38,7 @@ class ZapAmountMergeTest { ) = AccountZapPreferencesInternal(zapAmountChoices = zap, onchainZapAmountChoices = onchain) @Test - fun unionsDefaultsSortedAndDeduped() { + fun unionsDefaultsPreservingOrderAndDeduped() { assertEquals( listOf(21L, 50L, 100L, 5_000L), mergeZapAmounts(zaps(DefaultZapAmounts, DefaultOnchainZapAmounts)), @@ -44,10 +46,12 @@ class ZapAmountMergeTest { } @Test - fun preservesCustomAmountsFromBothLists() { + fun preservesCustomOrderAndAmountsFromBothLists() { // Simulates a sync from an older client that still split the two lists. + // First-seen order is kept (the Lightning list as-is, then on-chain + // extras), so a user's deliberate ordering isn't silently re-sorted. assertEquals( - listOf(10L, 21L, 100L, 1_000L, 7_777L), + listOf(21L, 100L, 10L, 7_777L, 1_000L), mergeZapAmounts(zaps(zap = listOf(21L, 100L, 10L), onchain = listOf(7_777L, 1_000L))), ) } From bbec99af75aad0161ef1bfc8454ad33e4b0f6c2f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 21:40:45 +0000 Subject: [PATCH 07/27] fix(zap): shrink reload cashu logo to one rail's footprint The NEEDS_RELOAD affordance rendered two full 18dp icons side by side (cashu + "+"), making the chip nearly twice as wide as the other rails. Overlay the "+" as a small 11dp corner badge on the dimmed cashu logo so it occupies a single logo's footprint like Lightning/on-chain. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index ece972c6f8..e8c5090777 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -2195,9 +2195,10 @@ private fun UnifiedZapAmountChip( } if (cashuReloadable) { // Funds exist but in the wrong mint — a dimmed cashu logo with a - // "+" badge; tap opens the Reload Mint screen to top it up first. + // small "+" corner badge; tap opens the Reload Mint screen to top + // it up first. Kept to one logo's footprint like the other rails. RailLogo(onClick = { onReloadNutzap(amountInSats) }) { - Row(verticalAlignment = CenterVertically) { + Box(contentAlignment = Alignment.BottomEnd) { Material3Icon( imageVector = CustomHashTagIcons.Cashu, contentDescription = stringRes(R.string.reload_mint_title), @@ -2207,7 +2208,7 @@ private fun UnifiedZapAmountChip( Icon( symbol = MaterialSymbols.AddCircle, contentDescription = null, - modifier = Size18Modifier, + modifier = Modifier.size(11.dp), tint = MaterialTheme.colorScheme.primary, ) } From c714eba482835915f74518b568d8f9d0dfaf1123 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 21:53:37 +0000 Subject: [PATCH 08/27] feat(zap): rework top-up screen (rename, editable amount, clearer source, live balances) + chip ordering Top-up (formerly "Reload Mint") screen: - Renamed throughout to "Top up mint" / "Top up & send zap". - Amount is now editable (numeric field, seeded from the tapped preset). The VM recomputes shortfall/sources/fees on every edit; if the chosen amount already fits the target mint, the screen drops the funding step and just sends. - Selected funding source is now obvious: radio button + 2dp primary border + primaryContainer fill, instead of a faint 12%-alpha tint. - Each source's balance/description moved to its own line under the title, so the Lightning explanation wraps instead of clipping. - Removed the redundant "Your balances" list (it duplicated the per-mint balance the Funds-from rows already show); the target's current balance now sits in the destination card. - Summary reads "Send AAA sats from XXX to YYY, fee Z sats" (or "Send AAA sats to YYY" when no top-up is needed). - Balances are now reactive: the VM observes the wallet's tokenEntries/mints flows and rebuilds as proofs arrive from relays, instead of a one-time snapshot that could show only the first-loaded mint. Zap chip: - The preferred rail (what a plain tap triggers) is rendered first and in full colour; the alternative rails follow to its right in monochrome. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../amethyst/ui/note/ReactionsRow.kt | 120 +++++++----- .../loggedIn/wallet/ReloadMintScreen.kt | 179 ++++++++++++------ .../loggedIn/wallet/ReloadMintViewModel.kt | 85 ++++++--- amethyst/src/main/res/values/strings.xml | 15 +- 4 files changed, 260 insertions(+), 139 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index e8c5090777..7f0044f530 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -2181,63 +2181,85 @@ private fun UnifiedZapAmountChip( textAlign = TextAlign.Center, ) Spacer(Modifier.width(6.dp)) - if (cashuReady) { - RailLogo(onClick = { onNutzap(amountInSats) }) { - // CustomHashTagIcons.Cashu is a multi-tone logo; tint - // Unspecified preserves its cashu-orange brand cue. - Material3Icon( - imageVector = CustomHashTagIcons.Cashu, - contentDescription = stringRes(R.string.nutzap), - modifier = Size18Modifier, - tint = Color.Unspecified, - ) + // The preferred rail (what a plain tap on the amount triggers) is + // rendered first and in full colour; the alternatives follow to its + // right in monochrome so the default choice reads at a glance. + val mono = MaterialTheme.colorScheme.onSurface + val present = + buildList { + if (cashuReady) add(ZapRail.CASHU) + if (cashuReloadable) add(ZapRail.RELOAD) + if (lightningReady) add(ZapRail.LIGHTNING) + if (onchainReady) add(ZapRail.ONCHAIN) } - } - if (cashuReloadable) { - // Funds exist but in the wrong mint — a dimmed cashu logo with a - // small "+" corner badge; tap opens the Reload Mint screen to top - // it up first. Kept to one logo's footprint like the other rails. - RailLogo(onClick = { onReloadNutzap(amountInSats) }) { - Box(contentAlignment = Alignment.BottomEnd) { - Material3Icon( - imageVector = CustomHashTagIcons.Cashu, - contentDescription = stringRes(R.string.reload_mint_title), - modifier = Size18Modifier.alpha(0.5f), - tint = Color.Unspecified, - ) - Icon( - symbol = MaterialSymbols.AddCircle, - contentDescription = null, - modifier = Modifier.size(11.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } + val preferred = + when { + cashuReady -> ZapRail.CASHU + lightningReady -> ZapRail.LIGHTNING + cashuReloadable -> ZapRail.RELOAD + else -> ZapRail.ONCHAIN } - } - if (lightningReady) { - RailLogo(onClick = { onLightningZap(amountInSats) }) { - Icon( - symbol = MaterialSymbols.Bolt, - contentDescription = null, - modifier = Size18Modifier, - tint = BitcoinOrange, - ) - } - } - if (onchainReady) { - RailLogo(onClick = { onOnchainAmount(amountInSats) }) { - Icon( - symbol = MaterialSymbols.CurrencyBitcoin, - contentDescription = null, - modifier = Size18Modifier, - tint = BitcoinOrange, - ) + (listOf(preferred) + present.filter { it != preferred }).forEachIndexed { index, rail -> + val colored = index == 0 + when (rail) { + ZapRail.CASHU -> + RailLogo(onClick = { onNutzap(amountInSats) }) { + // Cashu is a multi-tone logo; Unspecified keeps its + // brand colour, mono flattens it when not preferred. + Material3Icon( + imageVector = CustomHashTagIcons.Cashu, + contentDescription = stringRes(R.string.nutzap), + modifier = Size18Modifier, + tint = if (colored) Color.Unspecified else mono, + ) + } + ZapRail.RELOAD -> + RailLogo(onClick = { onReloadNutzap(amountInSats) }) { + // Funds exist but in the wrong mint — a dimmed cashu + // logo with a small "+" badge; tap opens the top-up + // screen. One logo's footprint like the other rails. + Box(contentAlignment = Alignment.BottomEnd) { + Material3Icon( + imageVector = CustomHashTagIcons.Cashu, + contentDescription = stringRes(R.string.reload_mint_title), + modifier = Size18Modifier.alpha(0.5f), + tint = if (colored) Color.Unspecified else mono, + ) + Icon( + symbol = MaterialSymbols.AddCircle, + contentDescription = null, + modifier = Modifier.size(11.dp), + tint = if (colored) MaterialTheme.colorScheme.primary else mono, + ) + } + } + ZapRail.LIGHTNING -> + RailLogo(onClick = { onLightningZap(amountInSats) }) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = null, + modifier = Size18Modifier, + tint = if (colored) BitcoinOrange else mono, + ) + } + ZapRail.ONCHAIN -> + RailLogo(onClick = { onOnchainAmount(amountInSats) }) { + Icon( + symbol = MaterialSymbols.CurrencyBitcoin, + contentDescription = null, + modifier = Size18Modifier, + tint = if (colored) BitcoinOrange else mono, + ) + } } } } } } +/** Payment rails a zap-amount chip can offer, in canonical display order. */ +private enum class ZapRail { CASHU, RELOAD, LIGHTNING, ONCHAIN } + @OptIn(ExperimentalFoundationApi::class) @Composable private fun RailLogo( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt index 28dbe4affe..5187fc2d21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -31,8 +32,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -44,17 +47,22 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -111,6 +119,10 @@ fun ReloadMintScreen( val ui by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + // Local source-of-truth for the editable amount, seeded from the tapped + // preset; pushes every edit into the VM which recomputes shortfall/sources. + var amountText by remember(requestId) { mutableStateOf(request.amountSats.toString()) } + if (ui.status is ReloadStatus.Done) { LaunchedEffect(Unit) { nav.popBack() } } @@ -145,6 +157,21 @@ fun ReloadMintScreen( color = MaterialTheme.colorScheme.placeholderText, ) + // ── Amount ──────────────────────────────────────────────────── + SectionHeader(stringRes(R.string.reload_mint_amount_label)) + OutlinedTextField( + value = amountText, + onValueChange = { input -> + val digits = input.filter { it.isDigit() }.take(12) + amountText = digits + viewModel.setAmount(digits.toLongOrNull() ?: 0L) + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + suffix = { Text(stringRes(R.string.sats)) }, + modifier = Modifier.fillMaxWidth(), + ) + // ── Destination ─────────────────────────────────────────────── SectionHeader(stringRes(R.string.reload_mint_section_to)) if (ui.targetOptions.size > 1) { @@ -158,65 +185,78 @@ fun ReloadMintScreen( } } } + val targetBalance = ui.balances.firstOrNull { it.mintUrl == ui.selectedTarget }?.balanceSats ?: 0L Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { Row( modifier = Modifier.fillMaxWidth().padding(14.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = shortMint(ui.selectedTarget), + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringRes(R.string.reload_mint_available, sats(targetBalance)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } Text( - text = shortMint(ui.selectedTarget), - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Text( - text = stringRes(R.string.reload_mint_needs_more, sats(ui.shortfallSats)), - color = MaterialTheme.colorScheme.primary, + text = + if (ui.shortfallSats > 0) { + stringRes(R.string.reload_mint_needs_more, sats(ui.shortfallSats)) + } else { + stringRes(R.string.reload_mint_funded) + }, + color = + if (ui.shortfallSats > 0) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.placeholderText + }, style = MaterialTheme.typography.bodyMedium, ) } } - // ── All balances ────────────────────────────────────────────── - SectionHeader(stringRes(R.string.reload_mint_section_balances)) - ui.balances.forEach { bal -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = shortMint(bal.mintUrl), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = - if (bal.mintUrl == ui.selectedTarget) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - modifier = Modifier.weight(1f), + // ── Funds from (only when a top-up is actually needed) ───────── + if (ui.shortfallSats > 0) { + SectionHeader(stringRes(R.string.reload_mint_section_from)) + ui.sources.forEach { source -> + SourceRow( + source = source, + selected = source == ui.selectedSource, + onSelect = { viewModel.selectSource(source) }, ) - Text(text = "${sats(bal.balanceSats)} sat", color = MaterialTheme.colorScheme.placeholderText) } } - // ── Source ──────────────────────────────────────────────────── - SectionHeader(stringRes(R.string.reload_mint_section_from)) - ui.sources.forEach { source -> - SourceRow( - source = source, - selected = source == ui.selectedSource, - onSelect = { viewModel.selectSource(source) }, - ) - } - Spacer(Modifier.height(4.dp)) - // ── Summary + action ────────────────────────────────────────── + // ── Summary ──────────────────────────────────────────────────── + val sourceLabel = + when (val src = ui.selectedSource) { + is ReloadSource.Mint -> shortMint(src.mintUrl) + ReloadSource.Lightning -> stringRes(R.string.reload_mint_source_lightning) + null -> "" + } Text( - text = stringRes(R.string.reload_mint_move_summary, sats(ui.shortfallSats), sats(ui.estFeeSats)), + text = + if (ui.shortfallSats > 0) { + stringRes( + R.string.reload_mint_move_summary, + sats(ui.shortfallSats), + sourceLabel, + shortMint(ui.selectedTarget), + sats(ui.estFeeSats), + ) + } else { + stringRes(R.string.reload_mint_send_summary, sats(ui.amountSats), shortMint(ui.selectedTarget)) + }, style = MaterialTheme.typography.bodyMedium, ) @@ -248,13 +288,15 @@ fun ReloadMintScreen( else -> { val source = ui.selectedSource - val enabled = source is ReloadSource.Lightning || (source is ReloadSource.Mint && source.canCover) + val enabled = + ui.amountSats > 0 && + (ui.shortfallSats == 0L || source is ReloadSource.Lightning || (source is ReloadSource.Mint && source.canCover)) Button( onClick = { viewModel.confirm() }, enabled = enabled, modifier = Modifier.fillMaxWidth(), ) { - Text(stringRes(R.string.reload_mint_confirm)) + Text(stringRes(if (ui.shortfallSats > 0) R.string.reload_mint_confirm else R.string.reload_mint_send_confirm)) } } } @@ -283,10 +325,16 @@ private fun SourceRow( is ReloadSource.Mint -> shortMint(source.mintUrl) ReloadSource.Lightning -> stringRes(R.string.reload_mint_pay_lightning) } - val trailing = + // Description / balance lives on its own row under the title so longer text + // (e.g. the Lightning explanation) wraps instead of clipping. + val subtitle = when (source) { is ReloadSource.Mint -> - if (source.canCover) "${sats(source.balanceSats)} sat" else stringRes(R.string.reload_mint_not_enough) + if (source.canCover) { + stringRes(R.string.reload_mint_available, sats(source.balanceSats)) + } else { + stringRes(R.string.reload_mint_not_enough) + } ReloadSource.Lightning -> stringRes(R.string.reload_mint_lightning_desc) } @@ -295,34 +343,45 @@ private fun SourceRow( CardDefaults.cardColors( containerColor = if (selected) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surfaceVariant }, ), + border = if (selected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, modifier = Modifier .fillMaxWidth() .selectable(selected = selected, enabled = enabled, onClick = onSelect), ) { Row( - modifier = Modifier.fillMaxWidth().padding(14.dp), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 14.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = title, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Text( - text = trailing, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.placeholderText, - ) + RadioButton(selected = selected, onClick = null, enabled = enabled) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = + when { + !enabled -> MaterialTheme.colorScheme.placeholderText + selected -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onSurface + }, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = + if (selected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.placeholderText + }, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt index 986e16c0b4..6328a4fc61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState import com.vitorpamplona.amethyst.model.nip60Cashu.describeMintError @@ -37,7 +38,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch /** Hand-off payload for the Reload Mint screen — the pending nutzap to fund. */ data class ReloadMintRequest( @@ -113,6 +116,8 @@ class ReloadMintViewModel : ViewModel() { * AccountViewModel scope, not this VM's). */ private var job: Job? = null + private var recipient: String? = null + private val _uiState = MutableStateFlow(ReloadUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -120,30 +125,43 @@ class ReloadMintViewModel : ViewModel() { accountViewModel: AccountViewModel, request: ReloadMintRequest, ) { + if (this.accountViewModel != null) return // already initialized this.accountViewModel = accountViewModel this.baseNote = request.baseNote val st = state ?: return val recipient = request.baseNote.author?.pubkeyHex ?: return + this.recipient = recipient + _uiState.update { it.copy(recipient = recipient, amountSats = request.amountSats) } + // Wallet proofs/mints arrive from relays and can land *after* the screen + // opens — a one-time snapshot would show only the first-loaded mint. Keep + // balances/targets in sync as the wallet fills in. + viewModelScope.launch { + combine(st.tokenEntries, st.mints) { _, _ -> Unit }.collect { rebuild() } + } + } + + /** Rebuild balances/targets/sources from the current wallet state, keeping + * the user's amount, chosen target, source pick, and status intact. */ + private fun rebuild() { + val st = state ?: return + val recipient = recipient ?: return val balances = st.peekMintBalances() - val targets = - st - .recipientSharedMints(recipient) - .sortedByDescending { balances[it] ?: 0L } - val defaultTarget = targets.firstOrNull() ?: return - - _uiState.value = - ReloadUiState( - recipient = recipient, - amountSats = request.amountSats, - balances = - balances.entries - .sortedByDescending { it.value } - .map { MintBalance(it.key, it.value) } - .toImmutableList(), - targetOptions = targets.toImmutableList(), - selectedTarget = defaultTarget, - ).let { recomputeFor(it, defaultTarget) } + val targets = st.recipientSharedMints(recipient).sortedByDescending { balances[it] ?: 0L } + _uiState.update { cur -> + val target = cur.selectedTarget.takeIf { it in targets } ?: targets.firstOrNull().orEmpty() + recomputeFor( + cur.copy( + balances = + balances.entries + .sortedByDescending { it.value } + .map { MintBalance(it.key, it.value) } + .toImmutableList(), + targetOptions = targets.toImmutableList(), + ), + target, + ) + } } /** Recompute shortfall, fee estimate, sources, and a default source. */ @@ -170,16 +188,24 @@ class ReloadMintViewModel : ViewModel() { .map { ReloadSource.Mint(it.mintUrl, it.balanceSats, canCover = it.balanceSats >= needFromSource) } val sources: List = mintSources + ReloadSource.Lightning - // Prefer the richest mint that can cover (no new sats); else Lightning. - val defaultSource: ReloadSource = - mintSources.filter { it.canCover }.maxByOrNull { it.balanceSats } ?: ReloadSource.Lightning + val coverableMints = mintSources.filter { it.canCover } + // Keep the user's current pick if it's still valid across the recompute + // (target/amount edits), otherwise default to the richest mint that can + // cover (no new sats), else Lightning. + val keep = + when (val prev = base.selectedSource) { + is ReloadSource.Lightning -> ReloadSource.Lightning + is ReloadSource.Mint -> coverableMints.firstOrNull { it.mintUrl == prev.mintUrl } + null -> null + } + val selectedSource: ReloadSource = keep ?: coverableMints.maxByOrNull { it.balanceSats } ?: ReloadSource.Lightning return base.copy( selectedTarget = target, shortfallSats = shortfall, estFeeSats = estFee, sources = sources.toImmutableList(), - selectedSource = defaultSource, + selectedSource = selectedSource, ) } @@ -191,11 +217,17 @@ class ReloadMintViewModel : ViewModel() { _uiState.update { it.copy(selectedSource = source) } } + /** Re-target the reload to a custom amount (the picker amount is the default). */ + fun setAmount(sats: Long) { + _uiState.update { recomputeFor(it.copy(amountSats = sats.coerceAtLeast(0L)), it.selectedTarget) } + } + fun confirm() { val vm = accountViewModel ?: return val note = baseNote ?: return val s = _uiState.value val source = s.selectedSource ?: return + if (s.amountSats <= 0L) return // In-flight guard: only start from a resting state. Without it a double // tap (or the Failed-state Retry) launches a second pipeline — two mint @@ -212,9 +244,12 @@ class ReloadMintViewModel : ViewModel() { job = vm.launchSigner { try { - when (source) { - is ReloadSource.Mint -> rebalanceThenZap(source.mintUrl, s.selectedTarget, moveSats, note, s.amountSats) - ReloadSource.Lightning -> reloadFromLightningThenZap(s.selectedTarget, moveSats, note, s.amountSats) + when { + // The chosen amount already fits in the target mint (e.g. + // the user lowered it) — no top-up needed, just send. + s.shortfallSats <= 0L -> sendNutzapAndFinish(note, s.amountSats) + source is ReloadSource.Mint -> rebalanceThenZap(source.mintUrl, s.selectedTarget, moveSats, note, s.amountSats) + source is ReloadSource.Lightning -> reloadFromLightningThenZap(s.selectedTarget, moveSats, note, s.amountSats) } } catch (e: CancellationException) { throw e // screen left mid-flow — don't mask as a Failed state diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 9f8b9292e1..9db4772f28 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -871,19 +871,24 @@ Quick Zap Amounts Shown when pressing the zap button. Each amount can be paid by any rail the recipient supports — Lightning, Cashu, or on-chain (on-chain only for larger amounts). Tap an amount to remove it. If you leave it empty, it will open the dialog to insert an amount every time. Send on-chain instead - Reload Mint + Top up mint Top up the recipient\'s mint so you can send this nutzap in ecash. - Mint to reload - Your balances + Amount to send + Mint to top up Funds from Pay with Lightning + Lightning Mint new ecash from your Lightning wallet Not enough here - Move funds & send zap + %1$s sats available + Top up & send zap + Send zap Waiting for payment… Try again - Move %1$s sat · fee ≈ %2$s sat + Send %1$s sats from %2$s to %3$s, fee %4$s sats + Send %1$s sats to %2$s needs %1$s sat more + funded Copy invoice Zap Privacy Controls how your identity is shown when you send a zap. From 9386aa6eb2a1232046387e0f442af4458d96ad29 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 22:04:36 +0000 Subject: [PATCH 09/27] feat(zap): amount-tiered default rail + preferred logo left of the number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default rail is now amount-tiered: under 10 sats prefers cashu (Lightning min/fees make tiny zaps awkward), over 10k sats prefers an on-chain transaction, and the middle defaults to Lightning — each falling back through the remaining rails when the tier's rail isn't available. The tap action and the colored leftmost logo both follow this single `preferred` choice. - The preferred rail's logo now sits to the LEFT of the amount (in colour); the alternative rails follow on the right in monochrome. - Shrank the cashu logo to 15dp (~17%) so it no longer dwarfs the Material symbol rails it sits beside; the reload "+" badge scaled to match. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../amethyst/ui/note/ReactionsRow.kt | 211 ++++++++++-------- 1 file changed, 121 insertions(+), 90 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 7f0044f530..43a7f66b9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -2122,10 +2122,13 @@ fun ZapAmountChoicePopupContent( * - **On-chain** when a backend is configured and the amount clears * [MIN_ONCHAIN_ZAP_SATS]; UTXO sufficiency stays a send-time check. * - * Tapping the amount fires the cheapest/fastest available rail (cashu funded → - * Lightning → cashu reload → on-chain); tapping a specific logo forces that - * rail. Long-press opens the amount-preset editor. A pill with no payable rail - * is not rendered. + * Tapping the amount fires the amount-tiered default rail: under + * [CASHU_PREFERRED_BELOW_SATS] cashu, over [ONCHAIN_PREFERRED_ABOVE_SATS] + * on-chain, and Lightning in between (falling back through the other rails when + * the tier's rail isn't available). That default logo sits to the left of the + * amount in colour; the alternatives follow on the right in monochrome and can + * be tapped to force their rail. Long-press opens the amount-preset editor. A + * pill with no payable rail is not rendered. */ @OptIn(ExperimentalFoundationApi::class) @Composable @@ -2146,21 +2149,36 @@ private fun UnifiedZapAmountChip( if (!cashuReady && !cashuReloadable && !lightningReady && !onchainReady) return - val defaultAction: () -> Unit = - when { - cashuReady -> { - { onNutzap(amountInSats) } - } - lightningReady -> { - { onLightningZap(amountInSats) } - } - cashuReloadable -> { - { onReloadNutzap(amountInSats) } - } - else -> { - { onOnchainAmount(amountInSats) } - } + // Default rail is amount-tiered: tiny zaps lean on cashu (Lightning min/fees + // make sub-10-sat payments awkward), large zaps settle on-chain, and the + // middle ground defaults to Lightning, with the remaining rails as fallbacks. + val present = + buildList { + if (cashuReady) add(ZapRail.CASHU) + if (cashuReloadable) add(ZapRail.RELOAD) + if (lightningReady) add(ZapRail.LIGHTNING) + if (onchainReady) add(ZapRail.ONCHAIN) } + val preferred = + when { + amountInSats < CASHU_PREFERRED_BELOW_SATS && cashuReady -> ZapRail.CASHU + amountInSats < CASHU_PREFERRED_BELOW_SATS && cashuReloadable -> ZapRail.RELOAD + amountInSats > ONCHAIN_PREFERRED_ABOVE_SATS && onchainReady -> ZapRail.ONCHAIN + lightningReady -> ZapRail.LIGHTNING + cashuReady -> ZapRail.CASHU + cashuReloadable -> ZapRail.RELOAD + else -> ZapRail.ONCHAIN + } + val others = present.filter { it != preferred } + + val defaultAction: () -> Unit = { + when (preferred) { + ZapRail.CASHU -> onNutzap(amountInSats) + ZapRail.RELOAD -> onReloadNutzap(amountInSats) + ZapRail.LIGHTNING -> onLightningZap(amountInSats) + ZapRail.ONCHAIN -> onOnchainAmount(amountInSats) + } + } Surface( shape = ButtonBorder, @@ -2171,86 +2189,24 @@ private fun UnifiedZapAmountChip( modifier = Modifier .combinedClickable(onClick = defaultAction, onLongClick = onChangeAmount) - .padding(start = 12.dp, end = 6.dp, top = 6.dp, bottom = 6.dp), + .padding(start = 8.dp, end = 6.dp, top = 6.dp, bottom = 6.dp), verticalAlignment = CenterVertically, ) { + // Preferred rail (what a plain tap triggers) sits to the LEFT of the + // amount in full colour; the alternatives follow to its right in + // monochrome, so the default choice reads at a glance. + ZapRailLogo(preferred, colored = true, amountInSats, onLightningZap, onNutzap, onOnchainAmount, onReloadNutzap) + Spacer(Modifier.width(6.dp)) Text( text = showAmount(amountInSats.toBigDecimal().setScale(1)), color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center, ) - Spacer(Modifier.width(6.dp)) - // The preferred rail (what a plain tap on the amount triggers) is - // rendered first and in full colour; the alternatives follow to its - // right in monochrome so the default choice reads at a glance. - val mono = MaterialTheme.colorScheme.onSurface - val present = - buildList { - if (cashuReady) add(ZapRail.CASHU) - if (cashuReloadable) add(ZapRail.RELOAD) - if (lightningReady) add(ZapRail.LIGHTNING) - if (onchainReady) add(ZapRail.ONCHAIN) - } - val preferred = - when { - cashuReady -> ZapRail.CASHU - lightningReady -> ZapRail.LIGHTNING - cashuReloadable -> ZapRail.RELOAD - else -> ZapRail.ONCHAIN - } - (listOf(preferred) + present.filter { it != preferred }).forEachIndexed { index, rail -> - val colored = index == 0 - when (rail) { - ZapRail.CASHU -> - RailLogo(onClick = { onNutzap(amountInSats) }) { - // Cashu is a multi-tone logo; Unspecified keeps its - // brand colour, mono flattens it when not preferred. - Material3Icon( - imageVector = CustomHashTagIcons.Cashu, - contentDescription = stringRes(R.string.nutzap), - modifier = Size18Modifier, - tint = if (colored) Color.Unspecified else mono, - ) - } - ZapRail.RELOAD -> - RailLogo(onClick = { onReloadNutzap(amountInSats) }) { - // Funds exist but in the wrong mint — a dimmed cashu - // logo with a small "+" badge; tap opens the top-up - // screen. One logo's footprint like the other rails. - Box(contentAlignment = Alignment.BottomEnd) { - Material3Icon( - imageVector = CustomHashTagIcons.Cashu, - contentDescription = stringRes(R.string.reload_mint_title), - modifier = Size18Modifier.alpha(0.5f), - tint = if (colored) Color.Unspecified else mono, - ) - Icon( - symbol = MaterialSymbols.AddCircle, - contentDescription = null, - modifier = Modifier.size(11.dp), - tint = if (colored) MaterialTheme.colorScheme.primary else mono, - ) - } - } - ZapRail.LIGHTNING -> - RailLogo(onClick = { onLightningZap(amountInSats) }) { - Icon( - symbol = MaterialSymbols.Bolt, - contentDescription = null, - modifier = Size18Modifier, - tint = if (colored) BitcoinOrange else mono, - ) - } - ZapRail.ONCHAIN -> - RailLogo(onClick = { onOnchainAmount(amountInSats) }) { - Icon( - symbol = MaterialSymbols.CurrencyBitcoin, - contentDescription = null, - modifier = Size18Modifier, - tint = if (colored) BitcoinOrange else mono, - ) - } + if (others.isNotEmpty()) { + Spacer(Modifier.width(6.dp)) + others.forEach { rail -> + ZapRailLogo(rail, colored = false, amountInSats, onLightningZap, onNutzap, onOnchainAmount, onReloadNutzap) } } } @@ -2260,6 +2216,81 @@ private fun UnifiedZapAmountChip( /** Payment rails a zap-amount chip can offer, in canonical display order. */ private enum class ZapRail { CASHU, RELOAD, LIGHTNING, ONCHAIN } +/** Below this, the default rail is cashu (Lightning min/fees make tiny zaps awkward). */ +private const val CASHU_PREFERRED_BELOW_SATS = 10L + +/** Above this, the default rail is an on-chain transaction. */ +private const val ONCHAIN_PREFERRED_ABOVE_SATS = 10_000L + +/** + * One tappable rail logo. [colored] renders it in its brand colour (used for the + * preferred rail); otherwise it's flattened to the on-surface monochrome tint. + */ +@Composable +private fun ZapRailLogo( + rail: ZapRail, + colored: Boolean, + amountInSats: Long, + onLightningZap: (Long) -> Unit, + onNutzap: (Long) -> Unit, + onOnchainAmount: (Long?) -> Unit, + onReloadNutzap: (Long) -> Unit, +) { + val mono = MaterialTheme.colorScheme.onSurface + when (rail) { + ZapRail.CASHU -> + RailLogo(onClick = { onNutzap(amountInSats) }) { + // Cashu is a multi-tone logo; Unspecified keeps its brand colour, + // mono flattens it when not the preferred rail. Drawn a touch + // smaller than the Material symbols, which it otherwise dwarfs. + Material3Icon( + imageVector = CustomHashTagIcons.Cashu, + contentDescription = stringRes(R.string.nutzap), + modifier = Modifier.size(15.dp), + tint = if (colored) Color.Unspecified else mono, + ) + } + ZapRail.RELOAD -> + RailLogo(onClick = { onReloadNutzap(amountInSats) }) { + // Funds exist but in the wrong mint — a dimmed cashu logo with a + // small "+" badge; tap opens the top-up screen. One logo's + // footprint like the other rails. + Box(contentAlignment = Alignment.BottomEnd) { + Material3Icon( + imageVector = CustomHashTagIcons.Cashu, + contentDescription = stringRes(R.string.reload_mint_title), + modifier = Modifier.size(15.dp).alpha(0.5f), + tint = if (colored) Color.Unspecified else mono, + ) + Icon( + symbol = MaterialSymbols.AddCircle, + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = if (colored) MaterialTheme.colorScheme.primary else mono, + ) + } + } + ZapRail.LIGHTNING -> + RailLogo(onClick = { onLightningZap(amountInSats) }) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = null, + modifier = Size18Modifier, + tint = if (colored) BitcoinOrange else mono, + ) + } + ZapRail.ONCHAIN -> + RailLogo(onClick = { onOnchainAmount(amountInSats) }) { + Icon( + symbol = MaterialSymbols.CurrencyBitcoin, + contentDescription = null, + modifier = Size18Modifier, + tint = if (colored) BitcoinOrange else mono, + ) + } + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun RailLogo( From c801b8e580bbad7f3119b915e22f65a7aa3133aa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 22:10:54 +0000 Subject: [PATCH 10/27] feat(zap): merge preferred logo into the amount tap-target; circular option buttons - The default action is now one tap target: the preferred rail's logo hugs the amount (no separate button), and a tap anywhere on icon+amount fires that rail. - Alternative rails render as distinct circular buttons (filled surfaceVariant circle) to their right, so it's clear they're individually tappable. - The amount takes the preferred rail's accent colour (BitcoinOrange for Lightning/on-chain, primary for reload; neutral for the multi-tone cashu logo, which has no single brand colour). - Reworked the preview to exercise every rail combination across the amount tiers (5 / 100 / 5k / 100k sats): all-rails, cashu-only, lightning-only, and a funds-split reload scenario. Refactor: split the per-rail logo (ZapRailIcon, click-free) from the tap target so it can be reused both inside the default area and inside RailButton. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../amethyst/ui/note/ReactionsRow.kt | 196 +++++++++++------- 1 file changed, 116 insertions(+), 80 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 43a7f66b9d..8989b52e8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -38,6 +38,7 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -2189,24 +2190,42 @@ private fun UnifiedZapAmountChip( modifier = Modifier .combinedClickable(onClick = defaultAction, onLongClick = onChangeAmount) - .padding(start = 8.dp, end = 6.dp, top = 6.dp, bottom = 6.dp), + .padding(start = 10.dp, end = 6.dp, top = 6.dp, bottom = 6.dp), verticalAlignment = CenterVertically, ) { - // Preferred rail (what a plain tap triggers) sits to the LEFT of the - // amount in full colour; the alternatives follow to its right in - // monochrome, so the default choice reads at a glance. - ZapRailLogo(preferred, colored = true, amountInSats, onLightningZap, onNutzap, onOnchainAmount, onReloadNutzap) - Spacer(Modifier.width(6.dp)) + // The default is ONE tap target: the preferred rail's logo hugs the + // amount (no separate button), and a plain tap anywhere here fires + // that rail; long-press edits the presets. The amount takes the + // preferred rail's accent colour. + val accent = + when (preferred) { + ZapRail.RELOAD -> MaterialTheme.colorScheme.primary + ZapRail.LIGHTNING, ZapRail.ONCHAIN -> BitcoinOrange + ZapRail.CASHU -> MaterialTheme.colorScheme.onSurface // multi-tone logo, no single brand colour + } + ZapRailIcon(preferred, colored = true) + Spacer(Modifier.width(5.dp)) Text( text = showAmount(amountInSats.toBigDecimal().setScale(1)), - color = MaterialTheme.colorScheme.onSurface, + color = accent, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center, ) - if (others.isNotEmpty()) { + // Alternatives as distinct circular buttons so they read as + // separately tappable, to the right of the amount. + others.forEach { rail -> Spacer(Modifier.width(6.dp)) - others.forEach { rail -> - ZapRailLogo(rail, colored = false, amountInSats, onLightningZap, onNutzap, onOnchainAmount, onReloadNutzap) + RailButton( + onClick = { + when (rail) { + ZapRail.CASHU -> onNutzap(amountInSats) + ZapRail.RELOAD -> onReloadNutzap(amountInSats) + ZapRail.LIGHTNING -> onLightningZap(amountInSats) + ZapRail.ONCHAIN -> onOnchainAmount(amountInSats) + } + }, + ) { + ZapRailIcon(rail, colored = false) } } } @@ -2223,115 +2242,132 @@ private const val CASHU_PREFERRED_BELOW_SATS = 10L private const val ONCHAIN_PREFERRED_ABOVE_SATS = 10_000L /** - * One tappable rail logo. [colored] renders it in its brand colour (used for the - * preferred rail); otherwise it's flattened to the on-surface monochrome tint. + * Just the rail's logo (no click target). [colored] renders it in its brand + * colour (the preferred rail); otherwise it's flattened to the on-surface + * monochrome tint. The cashu logo is drawn a touch smaller than the Material + * symbols, which it otherwise dwarfs. */ @Composable -private fun ZapRailLogo( +private fun ZapRailIcon( rail: ZapRail, colored: Boolean, - amountInSats: Long, - onLightningZap: (Long) -> Unit, - onNutzap: (Long) -> Unit, - onOnchainAmount: (Long?) -> Unit, - onReloadNutzap: (Long) -> Unit, ) { val mono = MaterialTheme.colorScheme.onSurface when (rail) { ZapRail.CASHU -> - RailLogo(onClick = { onNutzap(amountInSats) }) { - // Cashu is a multi-tone logo; Unspecified keeps its brand colour, - // mono flattens it when not the preferred rail. Drawn a touch - // smaller than the Material symbols, which it otherwise dwarfs. + Material3Icon( + imageVector = CustomHashTagIcons.Cashu, + contentDescription = stringRes(R.string.nutzap), + modifier = Modifier.size(15.dp), + tint = if (colored) Color.Unspecified else mono, + ) + ZapRail.RELOAD -> + // Funds exist but in the wrong mint — a dimmed cashu logo with a + // small "+" badge; tapping it opens the top-up screen. + Box(contentAlignment = Alignment.BottomEnd) { Material3Icon( imageVector = CustomHashTagIcons.Cashu, - contentDescription = stringRes(R.string.nutzap), - modifier = Modifier.size(15.dp), + contentDescription = stringRes(R.string.reload_mint_title), + modifier = Modifier.size(15.dp).alpha(0.5f), tint = if (colored) Color.Unspecified else mono, ) - } - ZapRail.RELOAD -> - RailLogo(onClick = { onReloadNutzap(amountInSats) }) { - // Funds exist but in the wrong mint — a dimmed cashu logo with a - // small "+" badge; tap opens the top-up screen. One logo's - // footprint like the other rails. - Box(contentAlignment = Alignment.BottomEnd) { - Material3Icon( - imageVector = CustomHashTagIcons.Cashu, - contentDescription = stringRes(R.string.reload_mint_title), - modifier = Modifier.size(15.dp).alpha(0.5f), - tint = if (colored) Color.Unspecified else mono, - ) - Icon( - symbol = MaterialSymbols.AddCircle, - contentDescription = null, - modifier = Modifier.size(10.dp), - tint = if (colored) MaterialTheme.colorScheme.primary else mono, - ) - } + Icon( + symbol = MaterialSymbols.AddCircle, + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = if (colored) MaterialTheme.colorScheme.primary else mono, + ) } ZapRail.LIGHTNING -> - RailLogo(onClick = { onLightningZap(amountInSats) }) { - Icon( - symbol = MaterialSymbols.Bolt, - contentDescription = null, - modifier = Size18Modifier, - tint = if (colored) BitcoinOrange else mono, - ) - } + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = null, + modifier = Size18Modifier, + tint = if (colored) BitcoinOrange else mono, + ) ZapRail.ONCHAIN -> - RailLogo(onClick = { onOnchainAmount(amountInSats) }) { - Icon( - symbol = MaterialSymbols.CurrencyBitcoin, - contentDescription = null, - modifier = Size18Modifier, - tint = if (colored) BitcoinOrange else mono, - ) - } + Icon( + symbol = MaterialSymbols.CurrencyBitcoin, + contentDescription = null, + modifier = Size18Modifier, + tint = if (colored) BitcoinOrange else mono, + ) } } -@OptIn(ExperimentalFoundationApi::class) +/** A circular, visibly-tappable button wrapping an alternative rail's logo. */ @Composable -private fun RailLogo( +private fun RailButton( onClick: () -> Unit, content: @Composable () -> Unit, ) { Box( modifier = Modifier - .padding(horizontal = 3.dp) + .size(28.dp) .clip(CircleShape) - .clickable(onClick = onClick) - .padding(2.dp), + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick), contentAlignment = Center, ) { content() } } +/** + * Exercises the chip across rail combinations and amount bands so the default + * (coloured, left of the amount) and the alternative circular buttons can be + * eyeballed. Amounts span the tiers: 5 (cashu), 100/5k (Lightning), 100k + * (on-chain). + */ @Preview @Composable fun ZapAmountChoicePopupPreview() { + val amounts = persistentListOf(5L, 100L, 5_000L, 100_000L) ThemeComparisonColumn { - ZapAmountChoicePopupContent( - amountChoices = persistentListOf(50L, 100L, 500L, 1_000L, 5_000L, 10_000L, 100_000L), - railCapability = - RailCapability( - hasCashu = true, - hasLightning = true, - hasOnchain = true, - cashuBestSingleMintSats = 1_000L, - cashuTotalWalletSats = 10_000L, - ), - onLightningZap = {}, - onNutzap = {}, - onOnchainAmount = {}, - onChangeAmount = {}, - ) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + ZapChipPreviewRow( + "All rails · cashu funded", + RailCapability(hasCashu = true, hasLightning = true, hasOnchain = true, cashuBestSingleMintSats = 1_000_000L, cashuTotalWalletSats = 1_000_000L), + amounts, + ) + ZapChipPreviewRow( + "Cashu only", + RailCapability(hasCashu = true, hasLightning = false, hasOnchain = false, cashuBestSingleMintSats = 1_000_000L, cashuTotalWalletSats = 1_000_000L), + amounts, + ) + ZapChipPreviewRow( + "Lightning only", + RailCapability(hasCashu = false, hasLightning = true, hasOnchain = false, cashuBestSingleMintSats = 0L, cashuTotalWalletSats = 0L), + amounts, + ) + ZapChipPreviewRow( + "Reload (funds split across mints)", + RailCapability(hasCashu = true, hasLightning = true, hasOnchain = true, cashuBestSingleMintSats = 10L, cashuTotalWalletSats = 1_000_000L), + amounts, + ) + } } } +@Composable +private fun ZapChipPreviewRow( + label: String, + caps: RailCapability, + amounts: ImmutableList, +) { + Text(label, style = MaterialTheme.typography.labelSmall) + ZapAmountChoicePopupContent( + amountChoices = amounts, + railCapability = caps, + onLightningZap = {}, + onNutzap = {}, + onOnchainAmount = {}, + onReloadNutzap = {}, + onChangeAmount = {}, + ) +} + fun showCount(count: Int?): String { if (count == null) return "" if (count == 0) return "" From 4ffb69c459081ad8714ad1f4878caafa86181eec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 22:18:31 +0000 Subject: [PATCH 11/27] =?UTF-8?q?fix(zap):=20shrink=20cashu=20chip=20logo?= =?UTF-8?q?=20further=20(15=E2=86=9213dp)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second pass on the cashu logo size — it still read large next to the Material symbol rails; drop to 13dp and scale the reload "+" badge to 9dp to match. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 8989b52e8b..beae267085 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -2258,7 +2258,7 @@ private fun ZapRailIcon( Material3Icon( imageVector = CustomHashTagIcons.Cashu, contentDescription = stringRes(R.string.nutzap), - modifier = Modifier.size(15.dp), + modifier = Modifier.size(13.dp), tint = if (colored) Color.Unspecified else mono, ) ZapRail.RELOAD -> @@ -2268,13 +2268,13 @@ private fun ZapRailIcon( Material3Icon( imageVector = CustomHashTagIcons.Cashu, contentDescription = stringRes(R.string.reload_mint_title), - modifier = Modifier.size(15.dp).alpha(0.5f), + modifier = Modifier.size(13.dp).alpha(0.5f), tint = if (colored) Color.Unspecified else mono, ) Icon( symbol = MaterialSymbols.AddCircle, contentDescription = null, - modifier = Modifier.size(10.dp), + modifier = Modifier.size(9.dp), tint = if (colored) MaterialTheme.colorScheme.primary else mono, ) } From d257749908a6d897c1e9a823733bf7c6f7d66625 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 22:41:27 +0000 Subject: [PATCH 12/27] =?UTF-8?q?feat(zap):=20top-up=20screen=20=E2=80=94?= =?UTF-8?q?=20avatar=20header,=20multiple=20LN=20wallets,=20separate=20top?= =?UTF-8?q?-up=20amount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header is now a visual: your avatar → cashu zap glyph → arrow → the recipient's avatar, centered, replacing the explanatory subtitle text. - Multiple Lightning wallets: each configured NWC wallet is its own "Funds from" option (by name), defaulting to the configured default wallet; the chosen wallet's URI is used to auto-pay. Falls back to a single external-invoice option when no NWC wallet is set up. - Separated the two amounts: the send (zap) amount is fixed; the editable field is now the top-up amount, defaulting to the shortfall and adjustable upward. Funding mints/moves the top-up; the nutzap always sends the fixed amount. - Muted the selected source styling — the radio button carries the signal, so the card uses a faint primary wash + a thin low-opacity border instead of a heavy 2dp outline and filled container. - Bottom summary now reads "Top up X sats to and zap Y sats · fee ≈ Z sats" (and "Zap Y sats from " when no top-up is needed), using the recipient's display name. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../loggedIn/wallet/ReloadMintScreen.kt | 151 +++++++----- .../loggedIn/wallet/ReloadMintViewModel.kt | 219 ++++++++++++------ amethyst/src/main/res/values/strings.xml | 9 +- 3 files changed, 248 insertions(+), 131 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt index 5187fc2d21..03191624a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable @@ -60,6 +61,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType @@ -68,16 +70,20 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.hashtags.Cashu +import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.Note 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.showAmount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText import java.util.UUID +import androidx.compose.material3.Icon as Material3Icon /** * Stash the pending nutzap and open the Reload Mint screen. Called from the zap @@ -119,9 +125,17 @@ fun ReloadMintScreen( val ui by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current - // Local source-of-truth for the editable amount, seeded from the tapped - // preset; pushes every edit into the VM which recomputes shortfall/sources. - var amountText by remember(requestId) { mutableStateOf(request.amountSats.toString()) } + // Local source-of-truth for the editable top-up field. The send amount is + // fixed; the top-up defaults to the shortfall the VM computes once the wallet + // has loaded, and every edit is pushed back so the VM recomputes sources/fees. + var topUpText by remember(requestId) { mutableStateOf("") } + var topUpSeeded by remember(requestId) { mutableStateOf(false) } + LaunchedEffect(ui.topUpSats) { + if (!topUpSeeded && ui.topUpSats > 0L) { + topUpText = ui.topUpSats.toString() + topUpSeeded = true + } + } if (ui.status is ReloadStatus.Done) { LaunchedEffect(Unit) { nav.popBack() } @@ -151,26 +165,57 @@ fun ReloadMintScreen( .padding(horizontal = 20.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Text( - text = stringRes(R.string.reload_mint_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.placeholderText, - ) + // ── Header: you → cashu zap → recipient ──────────────────────── + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture( + userHex = accountViewModel.account.userProfile().pubkeyHex, + size = 54.dp, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(Modifier.width(14.dp)) + Material3Icon( + imageVector = CustomHashTagIcons.Cashu, + contentDescription = null, + modifier = Modifier.size(26.dp), + tint = Color.Unspecified, + ) + Spacer(Modifier.width(6.dp)) + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowForward, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(14.dp)) + UserPicture( + userHex = ui.recipient, + size = 54.dp, + accountViewModel = accountViewModel, + nav = nav, + ) + } - // ── Amount ──────────────────────────────────────────────────── - SectionHeader(stringRes(R.string.reload_mint_amount_label)) - OutlinedTextField( - value = amountText, - onValueChange = { input -> - val digits = input.filter { it.isDigit() }.take(12) - amountText = digits - viewModel.setAmount(digits.toLongOrNull() ?: 0L) - }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - suffix = { Text(stringRes(R.string.sats)) }, - modifier = Modifier.fillMaxWidth(), - ) + // ── Top-up amount (editable; only when the target is short) ──── + if (ui.shortfallSats > 0L) { + SectionHeader(stringRes(R.string.reload_mint_topup_label)) + OutlinedTextField( + value = topUpText, + onValueChange = { input -> + val digits = input.filter { it.isDigit() }.take(12) + topUpText = digits + viewModel.setTopUp(digits.toLongOrNull() ?: 0L) + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + suffix = { Text(stringRes(R.string.sats)) }, + modifier = Modifier.fillMaxWidth(), + ) + } // ── Destination ─────────────────────────────────────────────── SectionHeader(stringRes(R.string.reload_mint_section_to)) @@ -237,25 +282,21 @@ fun ReloadMintScreen( Spacer(Modifier.height(4.dp)) - // ── Summary ──────────────────────────────────────────────────── - val sourceLabel = - when (val src = ui.selectedSource) { - is ReloadSource.Mint -> shortMint(src.mintUrl) - ReloadSource.Lightning -> stringRes(R.string.reload_mint_source_lightning) - null -> "" - } + // ── Summary: what we top up and who we zap ───────────────────── + val recipientName = ui.recipientName.ifBlank { stringRes(R.string.reload_mint_recipient_fallback) } Text( text = if (ui.shortfallSats > 0) { stringRes( - R.string.reload_mint_move_summary, - sats(ui.shortfallSats), - sourceLabel, + R.string.reload_mint_summary, + sats(maxOf(ui.topUpSats, ui.shortfallSats)), shortMint(ui.selectedTarget), + recipientName, + sats(ui.sendSats), sats(ui.estFeeSats), ) } else { - stringRes(R.string.reload_mint_send_summary, sats(ui.amountSats), shortMint(ui.selectedTarget)) + stringRes(R.string.reload_mint_summary_funded, recipientName, sats(ui.sendSats), shortMint(ui.selectedTarget)) }, style = MaterialTheme.typography.bodyMedium, ) @@ -289,8 +330,13 @@ fun ReloadMintScreen( else -> { val source = ui.selectedSource val enabled = - ui.amountSats > 0 && - (ui.shortfallSats == 0L || source is ReloadSource.Lightning || (source is ReloadSource.Mint && source.canCover)) + ui.sendSats > 0 && + ( + ui.shortfallSats == 0L || + source is ReloadSource.LightningWallet || + source is ReloadSource.LightningExternal || + (source is ReloadSource.Mint && source.canCover) + ) Button( onClick = { viewModel.confirm() }, enabled = enabled, @@ -319,11 +365,17 @@ private fun SourceRow( selected: Boolean, onSelect: () -> Unit, ) { - val enabled = source is ReloadSource.Lightning || (source is ReloadSource.Mint && source.canCover) + val enabled = + when (source) { + is ReloadSource.Mint -> source.canCover + is ReloadSource.LightningWallet -> true + ReloadSource.LightningExternal -> true + } val title = when (source) { is ReloadSource.Mint -> shortMint(source.mintUrl) - ReloadSource.Lightning -> stringRes(R.string.reload_mint_pay_lightning) + is ReloadSource.LightningWallet -> source.name + ReloadSource.LightningExternal -> stringRes(R.string.reload_mint_pay_lightning) } // Description / balance lives on its own row under the title so longer text // (e.g. the Lightning explanation) wraps instead of clipping. @@ -335,20 +387,23 @@ private fun SourceRow( } else { stringRes(R.string.reload_mint_not_enough) } - ReloadSource.Lightning -> stringRes(R.string.reload_mint_lightning_desc) + is ReloadSource.LightningWallet -> stringRes(R.string.reload_mint_lightning_desc) + ReloadSource.LightningExternal -> stringRes(R.string.reload_mint_lightning_desc) } Card( + // Muted selection: the radio carries the signal, so the fill/border stay + // subtle (a faint primary wash + a thin, low-opacity outline). colors = CardDefaults.cardColors( containerColor = if (selected) { - MaterialTheme.colorScheme.primaryContainer + MaterialTheme.colorScheme.primary.copy(alpha = 0.08f) } else { MaterialTheme.colorScheme.surfaceVariant }, ), - border = if (selected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, + border = if (selected) BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)) else null, modifier = Modifier .fillMaxWidth() @@ -364,22 +419,14 @@ private fun SourceRow( Text( text = title, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - color = - when { - !enabled -> MaterialTheme.colorScheme.placeholderText - selected -> MaterialTheme.colorScheme.onPrimaryContainer - else -> MaterialTheme.colorScheme.onSurface - }, + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) Text( text = subtitle, style = MaterialTheme.typography.bodySmall, - color = - if (selected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.placeholderText - }, + color = MaterialTheme.colorScheme.placeholderText, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt index 6328a4fc61..13838fcf77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState import com.vitorpamplona.amethyst.model.nip60Cashu.describeMintError import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -42,13 +43,13 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -/** Hand-off payload for the Reload Mint screen — the pending nutzap to fund. */ +/** Hand-off payload for the Top-up Mint screen — the pending nutzap to fund. */ data class ReloadMintRequest( val baseNote: Note, val amountSats: Long, ) -/** Where the reload funds come from. */ +/** Where the top-up funds come from. */ @Immutable sealed interface ReloadSource { /** Move existing cashu from another mint we hold (no new sats). */ @@ -58,8 +59,14 @@ sealed interface ReloadSource { val canCover: Boolean, ) : ReloadSource - /** Pay a fresh Lightning invoice to mint at the destination. */ - data object Lightning : ReloadSource + /** Mint fresh ecash by paying an invoice from a configured NWC wallet. */ + data class LightningWallet( + val walletId: String, + val name: String, + ) : ReloadSource + + /** No NWC wallet configured — show an invoice to pay from any external wallet. */ + data object LightningExternal : ReloadSource } /** A mint we hold a balance at, for the balances list. */ @@ -69,7 +76,7 @@ data class MintBalance( val balanceSats: Long, ) -/** Progress of the reload + send. */ +/** Progress of the top-up + send. */ @Immutable sealed interface ReloadStatus { data object Configuring : ReloadStatus @@ -79,7 +86,7 @@ sealed interface ReloadStatus { val progress: Float, ) : ReloadStatus - /** No NWC wallet — the user must pay [invoice] externally; we keep polling. */ + /** External Lightning — the user must pay [invoice]; we keep polling. */ data class AwaitingInvoice( val invoice: String, val sats: Long, @@ -95,10 +102,15 @@ sealed interface ReloadStatus { @Immutable data class ReloadUiState( val recipient: String = "", - val amountSats: Long = 0L, + val recipientName: String = "", + /** The zap amount — fixed; this is what the recipient receives. */ + val sendSats: Long = 0L, + /** The amount to add to the target mint — editable; defaults to the shortfall. */ + val topUpSats: Long = 0L, val balances: ImmutableList = persistentListOf(), val targetOptions: ImmutableList = persistentListOf(), val selectedTarget: String = "", + /** Minimum top-up the send needs: max(0, sendSats - targetBalance). */ val shortfallSats: Long = 0L, val estFeeSats: Long = 0L, val sources: ImmutableList = persistentListOf(), @@ -111,9 +123,9 @@ class ReloadMintViewModel : ViewModel() { private var baseNote: Note? = null private val state: CashuWalletState? get() = accountViewModel?.account?.cashuWalletState - /** The in-flight reload pipeline, so a second tap can be rejected and the - * whole flow cancelled when the screen leaves (it runs on the long-lived - * AccountViewModel scope, not this VM's). */ + /** The in-flight pipeline, so a second tap can be rejected and the whole flow + * cancelled when the screen leaves (it runs on the long-lived AccountViewModel + * scope, not this VM's). */ private var job: Job? = null private var recipient: String? = null @@ -131,7 +143,16 @@ class ReloadMintViewModel : ViewModel() { val st = state ?: return val recipient = request.baseNote.author?.pubkeyHex ?: return this.recipient = recipient - _uiState.update { it.copy(recipient = recipient, amountSats = request.amountSats) } + _uiState.update { + it.copy( + recipient = recipient, + recipientName = + request.baseNote.author + ?.toBestDisplayName() + .orEmpty(), + sendSats = request.amountSats, + ) + } // Wallet proofs/mints arrive from relays and can land *after* the screen // opens — a one-time snapshot would show only the first-loaded mint. Keep @@ -141,8 +162,8 @@ class ReloadMintViewModel : ViewModel() { } } - /** Rebuild balances/targets/sources from the current wallet state, keeping - * the user's amount, chosen target, source pick, and status intact. */ + /** Rebuild balances/targets/sources from the current wallet state, keeping the + * user's chosen target, top-up, source pick, and status intact. */ private fun rebuild() { val st = state ?: return val recipient = recipient ?: return @@ -150,7 +171,8 @@ class ReloadMintViewModel : ViewModel() { val targets = st.recipientSharedMints(recipient).sortedByDescending { balances[it] ?: 0L } _uiState.update { cur -> val target = cur.selectedTarget.takeIf { it in targets } ?: targets.firstOrNull().orEmpty() - recomputeFor( + val shortfall = (cur.sendSats - (balances[target] ?: 0L)).coerceAtLeast(0L) + recompute( cur.copy( balances = balances.entries @@ -158,47 +180,55 @@ class ReloadMintViewModel : ViewModel() { .map { MintBalance(it.key, it.value) } .toImmutableList(), targetOptions = targets.toImmutableList(), + // Default the top-up to the shortfall; bump it up if a wallet + // change made the current value too small to fund the send. + topUpSats = cur.topUpSats.coerceAtLeast(shortfall), ), target, ) } } - /** Recompute shortfall, fee estimate, sources, and a default source. */ - private fun recomputeFor( + /** Recompute shortfall, the amount we'd mint, fees, sources, and a default source. */ + private fun recompute( base: ReloadUiState, target: String, ): ReloadUiState { val balances = base.balances.associate { it.mintUrl to it.balanceSats } val targetBalance = balances[target] ?: 0L - val shortfall = (base.amountSats - targetBalance).coerceAtLeast(0L) - // Rough fee cushion for *enabling* a source — the real Lightning - // feeReserve is only known once rebalance() fetches the melt quote, so - // this is a heuristic (1%, min 2 sat). A source that clears the estimate - // but not the real quote fails recoverably (Failed → pick another), it - // doesn't move funds. - val estFee = (shortfall / 100L).coerceAtLeast(2L) - // A source must cover what we actually mint (shortfall + headroom buffer) - // plus that melt fee. - val needFromSource = shortfall + RELOAD_FEE_BUFFER_SATS + estFee + val shortfall = (base.sendSats - targetBalance).coerceAtLeast(0L) + val moveSats = moveSatsFor(base.topUpSats, shortfall) + // Rough fee cushion for *enabling* a source — the real Lightning feeReserve + // is only known once rebalance() fetches the melt quote, so this is a + // heuristic (1%, min 2 sat). A source that clears the estimate but not the + // real quote fails recoverably (Failed → pick another), it doesn't move funds. + val estFee = if (moveSats <= 0L) 0L else (moveSats / 100L).coerceAtLeast(2L) + val needFromSource = moveSats + estFee val mintSources = base.balances .filter { it.mintUrl != target } - .map { ReloadSource.Mint(it.mintUrl, it.balanceSats, canCover = it.balanceSats >= needFromSource) } + .map { ReloadSource.Mint(it.mintUrl, it.balanceSats, canCover = moveSats <= 0L || it.balanceSats >= needFromSource) } - val sources: List = mintSources + ReloadSource.Lightning - val coverableMints = mintSources.filter { it.canCover } - // Keep the user's current pick if it's still valid across the recompute - // (target/amount edits), otherwise default to the richest mint that can - // cover (no new sats), else Lightning. + val lightningSources: List = + nwcWallets() + .map { ReloadSource.LightningWallet(it.id, it.name) } + .ifEmpty { listOf(ReloadSource.LightningExternal) } + val sources = mintSources + lightningSources + + // Keep the current pick if still valid across the recompute; else default to + // the richest mint that can cover (no new sats), else the default LN wallet. val keep = when (val prev = base.selectedSource) { - is ReloadSource.Lightning -> ReloadSource.Lightning - is ReloadSource.Mint -> coverableMints.firstOrNull { it.mintUrl == prev.mintUrl } + is ReloadSource.Mint -> mintSources.firstOrNull { it.mintUrl == prev.mintUrl && it.canCover } + is ReloadSource.LightningWallet -> lightningSources.firstOrNull { it is ReloadSource.LightningWallet && it.walletId == prev.walletId } + ReloadSource.LightningExternal -> lightningSources.firstOrNull { it is ReloadSource.LightningExternal } null -> null } - val selectedSource: ReloadSource = keep ?: coverableMints.maxByOrNull { it.balanceSats } ?: ReloadSource.Lightning + val selectedSource: ReloadSource? = + keep + ?: mintSources.filter { it.canCover }.maxByOrNull { it.balanceSats } + ?: defaultLightningSource(lightningSources) return base.copy( selectedTarget = target, @@ -210,16 +240,21 @@ class ReloadMintViewModel : ViewModel() { } fun selectTarget(mintUrl: String) { - _uiState.update { recomputeFor(it, mintUrl) } + _uiState.update { s -> + val targetBalance = s.balances.firstOrNull { it.mintUrl == mintUrl }?.balanceSats ?: 0L + val shortfall = (s.sendSats - targetBalance).coerceAtLeast(0L) + // A different target changes how much is needed — reset the top-up to it. + recompute(s.copy(topUpSats = shortfall), mintUrl) + } } fun selectSource(source: ReloadSource) { _uiState.update { it.copy(selectedSource = source) } } - /** Re-target the reload to a custom amount (the picker amount is the default). */ - fun setAmount(sats: Long) { - _uiState.update { recomputeFor(it.copy(amountSats = sats.coerceAtLeast(0L)), it.selectedTarget) } + /** Set a custom top-up amount (defaults to the shortfall; can be increased). */ + fun setTopUp(sats: Long) { + _uiState.update { recompute(it.copy(topUpSats = sats.coerceAtLeast(0L)), it.selectedTarget) } } fun confirm() { @@ -227,29 +262,29 @@ class ReloadMintViewModel : ViewModel() { val note = baseNote ?: return val s = _uiState.value val source = s.selectedSource ?: return - if (s.amountSats <= 0L) return + if (s.sendSats <= 0L) return - // In-flight guard: only start from a resting state. Without it a double - // tap (or the Failed-state Retry) launches a second pipeline — two mint - // quotes at the target and two melts at the source, double-spending / - // double-minting. Flip to Working synchronously so the second call bails. + // In-flight guard: only start from a resting state. Without it a double tap + // (or the Failed-state Retry) launches a second pipeline — two mint quotes at + // the target and two melts at the source, double-spending / double-minting. + // Flip to Working synchronously so the second call bails. if (s.status !is ReloadStatus.Configuring && s.status !is ReloadStatus.Failed) return setStatus(ReloadStatus.Working("Starting", 0.05f)) - // Mint a hair more than the bare shortfall so the follow-up nutzap's own - // swap fee doesn't leave the target a sat short (see #RELOAD_FEE_BUFFER). - val moveSats = s.shortfallSats + RELOAD_FEE_BUFFER_SATS + val moveSats = moveSatsFor(s.topUpSats, s.shortfallSats) job?.cancel() job = vm.launchSigner { try { when { - // The chosen amount already fits in the target mint (e.g. - // the user lowered it) — no top-up needed, just send. - s.shortfallSats <= 0L -> sendNutzapAndFinish(note, s.amountSats) - source is ReloadSource.Mint -> rebalanceThenZap(source.mintUrl, s.selectedTarget, moveSats, note, s.amountSats) - source is ReloadSource.Lightning -> reloadFromLightningThenZap(s.selectedTarget, moveSats, note, s.amountSats) + // The target already covers the send — no top-up, just zap. + moveSats <= 0L -> sendNutzapAndFinish(note, s.sendSats) + source is ReloadSource.Mint -> rebalanceThenZap(source.mintUrl, s.selectedTarget, moveSats, note, s.sendSats) + source is ReloadSource.LightningWallet -> + reloadFromLightningThenZap(s.selectedTarget, moveSats, walletUriFor(source.walletId), note, s.sendSats) + source is ReloadSource.LightningExternal -> + reloadFromLightningThenZap(s.selectedTarget, moveSats, null, note, s.sendSats) } } catch (e: CancellationException) { throw e // screen left mid-flow — don't mask as a Failed state @@ -264,21 +299,22 @@ class ReloadMintViewModel : ViewModel() { targetMint: String, moveSats: Long, note: Note, - amount: Long, + sendSats: Long, ) { val st = state ?: return setStatus(ReloadStatus.Working("Moving funds", 0.1f)) st.rebalance(sourceMint, targetMint, moveSats) { p -> setStatus(ReloadStatus.Working("Moving funds", p.coerceIn(0.1f, 0.9f))) } - sendNutzapAndFinish(note, amount) + sendNutzapAndFinish(note, sendSats) } private suspend fun reloadFromLightningThenZap( targetMint: String, moveSats: Long, + walletUri: Nip47WalletConnect.Nip47URINorm?, note: Note, - amount: Long, + sendSats: Long, ) { val vm = accountViewModel ?: return val st = state ?: return @@ -287,11 +323,10 @@ class ReloadMintViewModel : ViewModel() { setStatus(ReloadStatus.Working("Requesting invoice", 0.1f)) val flow = ops.startMintFromLightning(targetMint, moveSats) - val walletUri = vm.account.settings.defaultZapPaymentRequest() if (walletUri != null) { setStatus(ReloadStatus.Working("Paying from your wallet", 0.35f)) - // Fire-and-forget: the mint-quote poll below is the source of truth - // for whether the payment actually landed. + // Fire-and-forget: the mint-quote poll below is the source of truth for + // whether the payment actually landed. runCatching { vm.account.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { } } @@ -301,8 +336,8 @@ class ReloadMintViewModel : ViewModel() { } // External payment can take a while; the poll runs on a job tied to the - // screen (cancelled in onCleared), so leaving stops it instead of - // hammering the mint for 3 minutes with nobody watching. + // screen (cancelled in onCleared), so leaving stops it instead of hammering + // the mint for 3 minutes with nobody watching. val attempts = 90 val delayMs = 2_000L var paid = false @@ -320,26 +355,26 @@ class ReloadMintViewModel : ViewModel() { } setStatus(ReloadStatus.Working("Issuing ecash", 0.85f)) ops.completeMintFromLightning(targetMint, flow.quoteEvent, moveSats) - sendNutzapAndFinish(note, amount) + sendNutzapAndFinish(note, sendSats) } /** - * Send the nutzap and only report [ReloadStatus.Done] once it actually - * succeeds. Suspends on the real send (throwing on failure) instead of the - * fire-and-forget AccountViewModel.sendNutzap, so a send that fails after a - * successful reload surfaces as Failed here rather than silently stranding - * the just-moved funds while the screen pops on a premature "done". + * Send the nutzap and only report [ReloadStatus.Done] once it actually succeeds. + * Suspends on the real send (throwing on failure) instead of the fire-and-forget + * AccountViewModel.sendNutzap, so a send that fails after a successful top-up + * surfaces as Failed here rather than silently stranding the just-moved funds + * while the screen pops on a premature "done". */ private suspend fun sendNutzapAndFinish( note: Note, - amount: Long, + sendSats: Long, ) { val st = state ?: return val recipient = note.author?.pubkeyHex ?: throw IllegalStateException("Recipient has no pubkey") val zappedEvent = note.toEventHint() ?: throw IllegalStateException("Nothing to zap") setStatus(ReloadStatus.Working("Sending zap", 0.95f)) st.sendNutzap( - amountSats = amount, + amountSats = sendSats, recipientPubKey = recipient, zappedEvent = zappedEvent, message = "", @@ -351,20 +386,56 @@ class ReloadMintViewModel : ViewModel() { _uiState.update { it.copy(status = status) } } + private fun nwcWallets() = + accountViewModel + ?.account + ?.settings + ?.nwcWallets + ?.value + .orEmpty() + + private fun walletUriFor(walletId: String) = nwcWallets().firstOrNull { it.id == walletId }?.uri + + /** The configured default NWC wallet's source, else the first Lightning source. */ + private fun defaultLightningSource(lightning: List): ReloadSource? { + val defaultId = + accountViewModel + ?.account + ?.settings + ?.defaultNwcWallet() + ?.id + return lightning.firstOrNull { it is ReloadSource.LightningWallet && it.walletId == defaultId } + ?: lightning.firstOrNull() + } + override fun onCleared() { - // The pipeline runs on the AccountViewModel scope, not this VM's, so it - // would outlive the screen — cancel it when the screen goes away. + // The pipeline runs on the AccountViewModel scope, not this VM's, so it would + // outlive the screen — cancel it when the screen goes away. job?.cancel() super.onCleared() } companion object { /** - * Extra sats minted at the target beyond the bare shortfall, so the - * follow-up nutzap's own swap fee doesn't leave the mint a hair short. - * Most mints charge no input fee; this small cushion covers the ones - * that do without meaningfully over-minting. + * Extra sats minted at the target beyond the bare shortfall, so the follow-up + * nutzap's own swap fee doesn't leave the mint a hair short. Most mints charge + * no input fee; this small cushion covers the ones that do. */ private const val RELOAD_FEE_BUFFER_SATS = 2L + + /** + * How much we actually mint/move: nothing when the target already covers the + * send; otherwise the larger of the user's top-up and the shortfall plus the + * swap-fee buffer, so the send can never come up short. + */ + private fun moveSatsFor( + topUp: Long, + shortfall: Long, + ): Long = + when { + topUp > 0L -> maxOf(topUp, shortfall + RELOAD_FEE_BUFFER_SATS) + shortfall > 0L -> shortfall + RELOAD_FEE_BUFFER_SATS + else -> 0L + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 9db4772f28..dd47ef12d6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -872,12 +872,10 @@ Shown when pressing the zap button. Each amount can be paid by any rail the recipient supports — Lightning, Cashu, or on-chain (on-chain only for larger amounts). Tap an amount to remove it. If you leave it empty, it will open the dialog to insert an amount every time. Send on-chain instead Top up mint - Top up the recipient\'s mint so you can send this nutzap in ecash. - Amount to send + Top up amount Mint to top up Funds from Pay with Lightning - Lightning Mint new ecash from your Lightning wallet Not enough here %1$s sats available @@ -885,8 +883,9 @@ Send zap Waiting for payment… Try again - Send %1$s sats from %2$s to %3$s, fee %4$s sats - Send %1$s sats to %2$s + Top up %1$s sats to %2$s and zap %3$s %4$s sats · fee ≈ %5$s sats + Zap %1$s %2$s sats from %3$s + the recipient needs %1$s sat more funded Copy invoice From 5d6a0e542df083d9e36c671ac48586a37fb58b42 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 22:54:08 +0000 Subject: [PATCH 13/27] feat(zap settings): preset chips mirror the live feed chip (rails + default), drop bolt emoji MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quick-zap preset chips now render like the real feed chip instead of a plain "⚡ N" InputChip: the amount with the rails a preset of that size could use — the amount-tier default rail in colour on the left, the alternatives (cashu / Lightning / on-chain) in monochrome — with an X to remove. The bolt emoji is gone; Lightning is the Material Symbols bolt, matching the feed. Refactor: ZapRail, ZapRailIcon, the tier thresholds, and a new previewRailsFor / previewPreferredRail / zapRailAccent are now internal so both the feed chip and the settings preview share one source of truth for rail choice, ordering, colouring, and the default highlight. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../amethyst/ui/note/ReactionsRow.kt | 52 ++++++++++--- .../amethyst/ui/note/UpdateZapAmountDialog.kt | 75 +++++++++++++------ 2 files changed, 94 insertions(+), 33 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index beae267085..6d3e23f2d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -2197,12 +2197,7 @@ private fun UnifiedZapAmountChip( // amount (no separate button), and a plain tap anywhere here fires // that rail; long-press edits the presets. The amount takes the // preferred rail's accent colour. - val accent = - when (preferred) { - ZapRail.RELOAD -> MaterialTheme.colorScheme.primary - ZapRail.LIGHTNING, ZapRail.ONCHAIN -> BitcoinOrange - ZapRail.CASHU -> MaterialTheme.colorScheme.onSurface // multi-tone logo, no single brand colour - } + val accent = zapRailAccent(preferred, MaterialTheme.colorScheme.onSurface, MaterialTheme.colorScheme.primary) ZapRailIcon(preferred, colored = true) Spacer(Modifier.width(5.dp)) Text( @@ -2233,13 +2228,50 @@ private fun UnifiedZapAmountChip( } /** Payment rails a zap-amount chip can offer, in canonical display order. */ -private enum class ZapRail { CASHU, RELOAD, LIGHTNING, ONCHAIN } +internal enum class ZapRail { CASHU, RELOAD, LIGHTNING, ONCHAIN } /** Below this, the default rail is cashu (Lightning min/fees make tiny zaps awkward). */ -private const val CASHU_PREFERRED_BELOW_SATS = 10L +internal const val CASHU_PREFERRED_BELOW_SATS = 10L /** Above this, the default rail is an on-chain transaction. */ -private const val ONCHAIN_PREFERRED_ABOVE_SATS = 10_000L +internal const val ONCHAIN_PREFERRED_ABOVE_SATS = 10_000L + +/** + * Default rail for a preset amount with no recipient context (the settings + * preview): cashu under [CASHU_PREFERRED_BELOW_SATS], on-chain over + * [ONCHAIN_PREFERRED_ABOVE_SATS] (and at/above the on-chain minimum), else + * Lightning. Mirrors the live chip's tiering so the preview matches the feed. + */ +internal fun previewPreferredRail(amountInSats: Long): ZapRail = + when { + amountInSats < CASHU_PREFERRED_BELOW_SATS -> ZapRail.CASHU + amountInSats > ONCHAIN_PREFERRED_ABOVE_SATS && amountInSats >= MIN_ONCHAIN_ZAP_SATS -> ZapRail.ONCHAIN + else -> ZapRail.LIGHTNING + } + +/** Rails a preset of [amountInSats] could use, default first then the rest. */ +internal fun previewRailsFor(amountInSats: Long): List { + val preferred = previewPreferredRail(amountInSats) + val all = + buildList { + add(ZapRail.CASHU) + add(ZapRail.LIGHTNING) + if (amountInSats >= MIN_ONCHAIN_ZAP_SATS) add(ZapRail.ONCHAIN) + } + return listOf(preferred) + all.filter { it != preferred } +} + +/** Brand accent for a rail (for the amount text); cashu's multi-tone logo has none. */ +internal fun zapRailAccent( + rail: ZapRail, + onSurface: Color, + primary: Color, +): Color = + when (rail) { + ZapRail.RELOAD -> primary + ZapRail.LIGHTNING, ZapRail.ONCHAIN -> BitcoinOrange + ZapRail.CASHU -> onSurface + } /** * Just the rail's logo (no click target). [colored] renders it in its brand @@ -2248,7 +2280,7 @@ private const val ONCHAIN_PREFERRED_ABOVE_SATS = 10_000L * symbols, which it otherwise dwarfs. */ @Composable -private fun ZapRailIcon( +internal fun ZapRailIcon( rail: ZapRail, colored: Boolean, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index c1cc450e08..e13781f6c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -43,6 +43,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -63,11 +64,10 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.InputChip -import androidx.compose.material3.InputChipDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -82,6 +82,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType @@ -240,27 +241,9 @@ fun UpdateZapAmountContent( verticalArrangement = Arrangement.spacedBy(6.dp), ) { postViewModel.amountSet.forEach { amountInSats -> - InputChip( - selected = false, - onClick = { postViewModel.removeAmount(amountInSats) }, - label = { - Text( - text = "⚡ ${showAmount(amountInSats.toBigDecimal().setScale(1))}", - ) - }, - trailingIcon = { - Icon( - symbol = MaterialSymbols.Close, - contentDescription = stringRes(R.string.remove), - modifier = Modifier.size(InputChipDefaults.AvatarSize), - ) - }, - colors = - InputChipDefaults.inputChipColors( - containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), - labelColor = MaterialTheme.colorScheme.primary, - trailingIconColor = MaterialTheme.colorScheme.primary, - ), + ZapAmountPresetChip( + amountInSats = amountInSats, + onRemove = { postViewModel.removeAmount(amountInSats) }, ) } } @@ -648,6 +631,52 @@ fun UpdateZapAmountContent( } } +/** + * A quick-zap preset chip that mirrors the live feed chip: the amount with the + * rails it could pay on — the amount-tier default rail in colour on the left, + * the alternatives in monochrome — plus an X to remove it. No recipient context + * here, so the rails are the ones a preset of this size could use (cashu, + * Lightning, and on-chain at/above the minimum). + */ +@Composable +private fun ZapAmountPresetChip( + amountInSats: Long, + onRemove: () -> Unit, +) { + val rails = remember(amountInSats) { previewRailsFor(amountInSats) } + val accent = zapRailAccent(rails.first(), MaterialTheme.colorScheme.onSurface, MaterialTheme.colorScheme.primary) + Surface( + shape = ButtonBorder, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row( + modifier = Modifier.padding(start = 10.dp, end = 2.dp, top = 2.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ZapRailIcon(rails.first(), colored = true) + Spacer(Modifier.width(5.dp)) + Text( + text = showAmount(amountInSats.toBigDecimal().setScale(1)), + color = accent, + fontWeight = FontWeight.SemiBold, + ) + rails.drop(1).forEach { rail -> + Spacer(Modifier.width(4.dp)) + ZapRailIcon(rail, colored = false) + } + IconButton(onClick = onRemove, modifier = Modifier.size(30.dp)) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.remove), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + } + } +} + fun authenticate( title: String, context: Context, From c91ef86d4db2e4ea9529eb42b413264effb700dc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 23:06:29 +0000 Subject: [PATCH 14/27] refactor(zap/wallet): move NWC setup out of zap-amount settings to the Add-NWC screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Nostr Wallet Connect section (connect button, paste, QR, manual pubkey/relay/ secret) is removed from the zap-amount settings content. NWC setup now lives only in the wallet area: - The `nostr+walletconnect` connect deep link (`dlnwc`) now opens Wallet → Add NWC with the URI prefilled (Route.WalletAddNwc gained an optional nip47 arg), instead of the old shared NIP-47 setup screen. - UpdateZapAmountContent loses its nip47uri parameter and the whole wallet-connect block; both callers (zap settings + NIP-47 setup) updated. The NIP-47 setup screen keeps its Lightning-address and payment-targets sections (both also reachable from profile edit / EditPaymentTargets) and is no longer the deep-link target. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../vitorpamplona/amethyst/ui/MainActivity.kt | 4 +- .../amethyst/ui/navigation/AppNavigation.kt | 2 +- .../amethyst/ui/navigation/routes/Routes.kt | 4 +- .../amethyst/ui/note/UpdateZapAmountDialog.kt | 371 ------------------ .../loggedIn/settings/NIP47SetupScreen.kt | 1 - .../settings/UpdateZapAmountScreen.kt | 2 +- .../loggedIn/wallet/AddNwcWalletScreen.kt | 4 +- .../screen/loggedIn/wallet/AddWalletScreen.kt | 2 +- 8 files changed, 11 insertions(+), 379 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 739b415353..5eac5e817f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -240,7 +240,7 @@ fun uriToRoute( val nip47Uri = url.getQueryParameter("value")?.firstOrNull() if (nip47Uri != null) { Nip47WalletConnect.parse(nip47Uri) - return Route.Nip47NWCSetup(nip47Uri) + return Route.WalletAddNwc(nip47Uri) } } catch (e: Exception) { if (e is CancellationException) throw e @@ -249,7 +249,7 @@ fun uriToRoute( try { Nip47WalletConnect.parse(uri) - return Route.Nip47NWCSetup(uri) + return Route.WalletAddNwc(uri) } catch (e: Exception) { if (e is CancellationException) throw e } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index f7b8ac0192..55d93b3008 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -310,7 +310,7 @@ fun BuildNavigation( composableFromEnd { OnchainTransactionsScreen(accountViewModel, nav) } composableFromEndArgs { WalletDetailScreen(it.walletId, accountViewModel, nav) } composableFromEnd { AddWalletScreen(accountViewModel, nav) } - composableFromEnd { AddNwcWalletScreen(accountViewModel, nav) } + composableFromEndArgs { AddNwcWalletScreen(accountViewModel, nav, it.nip47) } composableFromEnd { AddCashuWalletScreen(accountViewModel, nav) } composableFromEnd { CashuWalletScreen(accountViewModel, nav) } composableFromEnd { CashuWalletSettingsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index af2d44aa01..37b685c1fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -200,7 +200,9 @@ sealed class Route { @Serializable object WalletAdd : Route() - @Serializable object WalletAddNwc : Route() + @Serializable data class WalletAddNwc( + val nip47: String? = null, + ) : Route() @Serializable object WalletAddCashu : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index e13781f6c4..136bafafe4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -20,31 +20,18 @@ */ package com.vitorpamplona.amethyst.ui.note -import android.app.Activity import android.app.KeyguardManager import android.content.Context import android.content.Intent import android.os.Build import androidx.activity.compose.ManagedActivityResultLauncher -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -61,33 +48,21 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf 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.graphics.Color -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -96,21 +71,14 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TitleExplainer -import com.vitorpamplona.amethyst.ui.components.util.getText -import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.getFragmentActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier -import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -139,15 +107,9 @@ fun UpdateZapAmountContentPreview() { fun UpdateZapAmountContent( postViewModel: UpdateZapAmountViewModel, onClose: () -> Unit, - nip47uri: String? = null, accountViewModel: AccountViewModel, trailingContent: @Composable ColumnScope.() -> Unit = {}, ) { - val context = LocalContext.current - val clipboardManager = LocalClipboard.current - val uri = LocalUriHandler.current - val scope = rememberCoroutineScope() - val zapTypes = listOf( Triple( @@ -177,36 +139,6 @@ fun UpdateZapAmountContent( zapTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() } - LaunchedEffect(accountViewModel, nip47uri) { - if (nip47uri != null) { - try { - postViewModel.updateNIP47(nip47uri) - } catch (e: IllegalArgumentException) { - if (e.message != null) { - accountViewModel.toastManager.toast( - stringRes(context, R.string.error_parsing_nip47_title), - stringRes(context, R.string.error_parsing_nip47, nip47uri, e.message!!), - ) - } else { - accountViewModel.toastManager.toast( - stringRes(context, R.string.error_parsing_nip47_title), - stringRes(context, R.string.error_parsing_nip47_no_error, nip47uri), - ) - } - } - } - } - - var qrScanning by remember { mutableStateOf(false) } - - // Expand manual config automatically when a wallet connection exists - var showManualConfig by remember { mutableStateOf(postViewModel.walletConnectPubkey.text.isNotBlank()) } - LaunchedEffect(postViewModel.walletConnectPubkey.text) { - if (postViewModel.walletConnectPubkey.text.isNotBlank()) { - showManualConfig = true - } - } - Column( modifier = Modifier @@ -322,309 +254,6 @@ fun UpdateZapAmountContent( ) } - // ── Section 3: Nostr Wallet Connect ─────────────────────────────────── - - HorizontalDivider( - modifier = Modifier.padding(vertical = 16.dp), - thickness = DividerThickness, - ) - - // Section header + connection status indicator - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringRes(R.string.wallet_connect_service), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.titleSmall, - ) - Text( - text = stringRes(R.string.wallet_connect_service_explainer), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(top = 2.dp), - ) - } - } - - // Animated connection status badge - val isConnected = postViewModel.walletConnectPubkey.text.isNotBlank() - val statusColor by animateColorAsState( - targetValue = if (isConnected) Color(0xFF4CAF50) else MaterialTheme.colorScheme.placeholderText, - animationSpec = tween(durationMillis = 400), - label = "nwc_status_color", - ) - - AnimatedContent( - targetState = isConnected, - transitionSpec = { fadeIn(tween(300)) togetherWith fadeOut(tween(300)) }, - label = "nwc_status_badge", - ) { connected -> - Row( - modifier = Modifier.padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Icon( - symbol = if (connected) MaterialSymbols.CheckCircle else MaterialSymbols.RadioButtonUnchecked, - contentDescription = null, - tint = statusColor, - modifier = Modifier.size(18.dp), - ) - Text( - text = - if (connected) { - stringRes(R.string.wallet_connect_status_connected) - } else { - stringRes(R.string.wallet_connect_status_not_connected) - }, - color = statusColor, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - - // Connect action buttons - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - // Primary "Connect Wallet" button — opens the NWC app deep link - OutlinedButton( - modifier = Modifier.weight(1f), - shape = ButtonBorder, - onClick = { - try { - uri.openUri( - "nostrnwc://connect?appname=Amethyst&appicon=https%3A%2F%2Fraw.githubusercontent.com%2Fvitorpamplona%2Famethyst%2Frefs%2Fheads%2Fmain%2Ficon.png&callback=amethyst%2Bwalletconnect%3A%2F%2Fdlnwc", - ) - onClose() - } catch (_: IllegalArgumentException) { - accountViewModel.toastManager.toast( - R.string.couldnt_find_nwc_wallets, - R.string.couldnt_find_nwc_wallets_description, - ) - } - }, - ) { - Icon( - symbol = MaterialSymbols.Add, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(text = stringRes(R.string.wallet_connect_connect_app)) - } - - Spacer(DoubleHorzSpacer) - - // Paste from clipboard - IconButton( - onClick = { - scope.launch { - val clipText = clipboardManager.getText() - try { - clipText?.let { postViewModel.copyFromClipboard(it) } - } catch (e: IllegalArgumentException) { - accountViewModel.toastManager.toast( - R.string.invalid_nip47_uri_title, - R.string.invalid_nip47_uri_description, - clipText ?: "", - ) - } - } - }, - ) { - Icon( - symbol = MaterialSymbols.ContentPaste, - contentDescription = stringRes(id = R.string.paste_from_clipboard), - modifier = Size24Modifier, - tint = MaterialTheme.colorScheme.primary, - ) - } - - // QR code scanner - IconButton(onClick = { qrScanning = true }) { - Icon( - painter = painterRes(R.drawable.ic_qrcode, 3), - contentDescription = stringRes(id = R.string.accessibility_scan_qr_code), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } - } - - if (qrScanning) { - SimpleQrCodeScanner { - qrScanning = false - if (!it.isNullOrEmpty()) { - try { - postViewModel.updateNIP47(it) - } catch (e: IllegalArgumentException) { - if (e.message != null) { - accountViewModel.toastManager.toast( - stringRes(context, R.string.error_parsing_nip47_title), - stringRes(context, R.string.error_parsing_nip47, it, e.message!!), - ) - } else { - accountViewModel.toastManager.toast( - stringRes(context, R.string.error_parsing_nip47_title), - stringRes(context, R.string.error_parsing_nip47_no_error, it), - ) - } - } - } - } - } - - // Expandable manual configuration section - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { showManualConfig = !showManualConfig } - .padding(vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringRes(R.string.wallet_connect_manual_config), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.weight(1f), - fontSize = Font14SP, - ) - Icon( - symbol = if (showManualConfig) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, - contentDescription = null, - tint = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.size(20.dp), - ) - } - - AnimatedVisibility( - visible = showManualConfig, - enter = expandVertically(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)) + fadeIn(), - exit = shrinkVertically(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)) + fadeOut(), - ) { - Column { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.wallet_connect_service_pubkey)) }, - value = postViewModel.walletConnectPubkey, - onValueChange = { postViewModel.walletConnectPubkey = it }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - ), - placeholder = { - Text( - text = "npub, hex", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.wallet_connect_service_relay)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.walletConnectRelay, - onValueChange = { postViewModel.walletConnectRelay = it }, - placeholder = { - Text( - text = "wss://relay.server.com", - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - }, - singleLine = true, - ) - } - - var showPassword by remember { mutableStateOf(false) } - - val secretContext = LocalContext.current - - val keyguardLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> - if (result.resultCode == Activity.RESULT_OK) { - showPassword = true - } - } - - val authTitle = stringRes(id = R.string.wallet_connect_service_show_secret) - - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.wallet_connect_service_secret)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.walletConnectSecret, - onValueChange = { postViewModel.walletConnectSecret = it }, - keyboardOptions = - KeyboardOptions( - autoCorrectEnabled = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - placeholder = { - Text( - text = stringRes(R.string.wallet_connect_service_secret_placeholder), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - IconButton( - onClick = { - if (!showPassword) { - authenticate( - title = authTitle, - context = secretContext, - keyguardLauncher = keyguardLauncher, - onApproved = { showPassword = true }, - onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, - ) - } else { - showPassword = false - } - }, - ) { - Icon( - symbol = - if (showPassword) { - MaterialSymbols.VisibilityOff - } else { - MaterialSymbols.Visibility - }, - contentDescription = - if (showPassword) { - stringRes(R.string.show_password) - } else { - stringRes(R.string.hide_password) - }, - ) - } - }, - visualTransformation = - if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - ) - } - } - } - trailingContent() Spacer(modifier = Modifier.height(16.dp)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt index c503ca3f19..ecc69c47b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt @@ -122,7 +122,6 @@ fun NIP47SetupScreen( paymentTargetsViewModel.refresh() nav.popBack() }, - nip47, accountViewModel, ) { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UpdateZapAmountScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UpdateZapAmountScreen.kt index 055620acd8..3c31bc59a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UpdateZapAmountScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UpdateZapAmountScreen.kt @@ -79,7 +79,7 @@ fun UpdateZapAmountScreen( UpdateZapAmountContent(postViewModel, onClose = { postViewModel.cancel() nav.popBack() - }, nip47, accountViewModel) + }, accountViewModel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddNwcWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddNwcWalletScreen.kt index ac9d53d132..73004d6428 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddNwcWalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddNwcWalletScreen.kt @@ -74,12 +74,14 @@ import kotlinx.coroutines.launch fun AddNwcWalletScreen( accountViewModel: AccountViewModel, nav: INav, + nip47Uri: String? = null, ) { val walletViewModel: WalletViewModel = viewModel() walletViewModel.init(accountViewModel) var walletName by remember { mutableStateOf("") } - var nwcUri by remember { mutableStateOf("") } + // Prefilled when arriving from the `nostr+walletconnect` connect deep link. + var nwcUri by remember { mutableStateOf(nip47Uri.orEmpty()) } var error by remember { mutableStateOf(null) } var qrScanning by remember { mutableStateOf(false) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt index b5a58153b0..0c4d4e90de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt @@ -97,7 +97,7 @@ fun AddWalletScreen( // chooser has done its job. Without this, completing the // add-wallet form pops back to the chooser, which is a // pointless dead end. - onClick = { nav.popUpTo(Route.WalletAddNwc, Route.WalletAdd::class) }, + onClick = { nav.popUpTo(Route.WalletAddNwc(), Route.WalletAdd::class) }, ) WalletTypeCard( icon = MaterialSymbols.AccountBalanceWallet, From 92b1af8a23fb8c69059b05eb625d4acd195fd560 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 23:11:49 +0000 Subject: [PATCH 15/27] feat(zap settings): drag-and-drop reordering of quick-zap preset chips Long-press a preset chip to pick it up and drag it to a new position in the FlowRow; release to drop. Hand-rolled (no new dependency): - VM gains moveAmount(from, to) to reorder the preset list. - Each chip records its bounds (boundsInParent) and is wrapped in key(amount) so reordering doesn't restart the in-flight drag gesture; the dragged chip follows the finger via graphicsLayer translation and lifts with zIndex. - The drop target is the chip whose bounds contain the pointer; on cross-over the list reorders and the drag offset is rebased so the chip stays under the finger. Identity (drag state, bounds map, target) is keyed by the amount value, not the slot index, to avoid stale-index bugs across reorders. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../amethyst/ui/note/UpdateZapAmountDialog.kt | 79 +++++++++++++++++-- .../ui/note/UpdateZapAmountViewModel.kt | 12 +++ 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 136bafafe4..1cca029442 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -32,6 +32,7 @@ import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -56,15 +57,25 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -162,8 +173,14 @@ fun UpdateZapAmountContent( modifier = Modifier.padding(bottom = 6.dp), ) - // Amount chips — animateContentSize gives a smooth expand/collapse when - // chips are added or removed + // Amount chips — long-press a chip to drag it to a new position; the X + // removes it. animateContentSize smooths add/remove. Identity (and the + // bounds map + drag target) is keyed by the amount value, not the slot + // index, so reordering doesn't restart the in-flight drag gesture. + var draggingAmount by remember { mutableStateOf(null) } + var dragOffset by remember { mutableStateOf(Offset.Zero) } + val itemBounds = remember { mutableStateMapOf() } + FlowRow( modifier = Modifier @@ -173,10 +190,58 @@ fun UpdateZapAmountContent( verticalArrangement = Arrangement.spacedBy(6.dp), ) { postViewModel.amountSet.forEach { amountInSats -> - ZapAmountPresetChip( - amountInSats = amountInSats, - onRemove = { postViewModel.removeAmount(amountInSats) }, - ) + key(amountInSats) { + val dragging = draggingAmount == amountInSats + ZapAmountPresetChip( + amountInSats = amountInSats, + onRemove = { postViewModel.removeAmount(amountInSats) }, + modifier = + Modifier + .onGloballyPositioned { itemBounds[amountInSats] = it.boundsInParent() } + .zIndex(if (dragging) 1f else 0f) + .graphicsLayer { + if (dragging) { + translationX = dragOffset.x + translationY = dragOffset.y + } + }.pointerInput(amountInSats) { + detectDragGesturesAfterLongPress( + onDragStart = { + draggingAmount = amountInSats + dragOffset = Offset.Zero + }, + onDragEnd = { + draggingAmount = null + dragOffset = Offset.Zero + }, + onDragCancel = { + draggingAmount = null + dragOffset = Offset.Zero + }, + onDrag = { change, amount -> + change.consume() + dragOffset += amount + val from = draggingAmount ?: return@detectDragGesturesAfterLongPress + val fromBounds = itemBounds[from] ?: return@detectDragGesturesAfterLongPress + val pointer = fromBounds.center + dragOffset + val targetAmount = + itemBounds.entries + .firstOrNull { (amt, rect) -> amt != from && rect.contains(pointer) } + ?.key + if (targetAmount != null) { + val targetBounds = itemBounds[targetAmount] + postViewModel.moveAmount( + postViewModel.amountSet.indexOf(from), + postViewModel.amountSet.indexOf(targetAmount), + ) + // Keep the chip under the finger across the slot swap. + if (targetBounds != null) dragOffset = pointer - targetBounds.center + } + }, + ) + }, + ) + } } } @@ -271,6 +336,7 @@ fun UpdateZapAmountContent( private fun ZapAmountPresetChip( amountInSats: Long, onRemove: () -> Unit, + modifier: Modifier = Modifier, ) { val rails = remember(amountInSats) { previewRailsFor(amountInSats) } val accent = zapRailAccent(rails.first(), MaterialTheme.colorScheme.onSurface, MaterialTheme.colorScheme.primary) @@ -278,6 +344,7 @@ private fun ZapAmountPresetChip( shape = ButtonBorder, color = MaterialTheme.colorScheme.surface, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = modifier, ) { Row( modifier = Modifier.padding(start = 10.dp, end = 2.dp, top = 2.dp, bottom = 2.dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt index 8d50017d9a..76d97fa4c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt @@ -83,6 +83,18 @@ class UpdateZapAmountViewModel : ViewModel() { amountSet = amountSet - amount } + /** Move the preset at [fromIndex] to [toIndex] (drag-and-drop reorder). */ + fun moveAmount( + fromIndex: Int, + toIndex: Int, + ) { + if (fromIndex == toIndex) return + val list = amountSet.toMutableList() + if (fromIndex !in list.indices || toIndex !in list.indices) return + list.add(toIndex, list.removeAt(fromIndex)) + amountSet = list + } + fun sendPost() { accountViewModel.launchSigner { sendPostSuspend() From 8c855a3fc621347c2ffd02f66b83851e2e700477 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 23:42:27 +0000 Subject: [PATCH 16/27] fix(zap chip): symmetric padding for single-rail chips, quieter alternatives + amount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - When a chip has no alternative rails, match the right inset to the left so the icon+amount pill stays symmetric (was cramped against the right edge). - Alternative-rail circles shrunk toward the amount's font height (28→22dp) with a smaller icon inside (ZapRailIcon gained a size param; alternatives render at 14dp), so they stay quiet next to the bigger coloured preferred logo. - The amount text is now neutral (onSurface) instead of taking the rail's brand colour — only the leading logo carries colour, so amounts don't shout across the feed. Dropped the now-unused zapRailAccent helper. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../amethyst/ui/note/ReactionsRow.kt | 59 +++++++++---------- .../amethyst/ui/note/UpdateZapAmountDialog.kt | 5 +- 2 files changed, 30 insertions(+), 34 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 6d3e23f2d7..363637a35f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -2190,24 +2190,27 @@ private fun UnifiedZapAmountChip( modifier = Modifier .combinedClickable(onClick = defaultAction, onLongClick = onChangeAmount) - .padding(start = 10.dp, end = 6.dp, top = 6.dp, bottom = 6.dp), + // With no alternative buttons on the right, match the left inset + // so the icon+amount pill stays symmetric; the trailing buttons + // supply their own right-side mass when present. + .padding(start = 10.dp, end = if (others.isEmpty()) 10.dp else 6.dp, top = 6.dp, bottom = 6.dp), verticalAlignment = CenterVertically, ) { // The default is ONE tap target: the preferred rail's logo hugs the // amount (no separate button), and a plain tap anywhere here fires - // that rail; long-press edits the presets. The amount takes the - // preferred rail's accent colour. - val accent = zapRailAccent(preferred, MaterialTheme.colorScheme.onSurface, MaterialTheme.colorScheme.primary) + // that rail; long-press edits the presets. Only the leading logo + // carries colour — the amount itself stays neutral so it doesn't + // shout across the feed. ZapRailIcon(preferred, colored = true) Spacer(Modifier.width(5.dp)) Text( text = showAmount(amountInSats.toBigDecimal().setScale(1)), - color = accent, + color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center, ) - // Alternatives as distinct circular buttons so they read as - // separately tappable, to the right of the amount. + // Alternatives as small, quiet circular buttons (icon smaller than the + // preferred logo) to the right of the amount. others.forEach { rail -> Spacer(Modifier.width(6.dp)) RailButton( @@ -2220,7 +2223,7 @@ private fun UnifiedZapAmountChip( } }, ) { - ZapRailIcon(rail, colored = false) + ZapRailIcon(rail, colored = false, size = 14.dp) } } } @@ -2261,36 +2264,26 @@ internal fun previewRailsFor(amountInSats: Long): List { return listOf(preferred) + all.filter { it != preferred } } -/** Brand accent for a rail (for the amount text); cashu's multi-tone logo has none. */ -internal fun zapRailAccent( - rail: ZapRail, - onSurface: Color, - primary: Color, -): Color = - when (rail) { - ZapRail.RELOAD -> primary - ZapRail.LIGHTNING, ZapRail.ONCHAIN -> BitcoinOrange - ZapRail.CASHU -> onSurface - } - /** - * Just the rail's logo (no click target). [colored] renders it in its brand - * colour (the preferred rail); otherwise it's flattened to the on-surface - * monochrome tint. The cashu logo is drawn a touch smaller than the Material - * symbols, which it otherwise dwarfs. + * Just the rail's logo (no click target), drawn at [size]. [colored] renders it + * in its brand colour (the preferred rail); otherwise it's flattened to the + * on-surface monochrome tint. The cashu logo is drawn ~0.72× so its multi-tone + * mark reads at the same optical size as the Material symbols. */ @Composable internal fun ZapRailIcon( rail: ZapRail, colored: Boolean, + size: Dp = 18.dp, ) { val mono = MaterialTheme.colorScheme.onSurface + val cashuSize = size * 0.72f when (rail) { ZapRail.CASHU -> Material3Icon( imageVector = CustomHashTagIcons.Cashu, contentDescription = stringRes(R.string.nutzap), - modifier = Modifier.size(13.dp), + modifier = Modifier.size(cashuSize), tint = if (colored) Color.Unspecified else mono, ) ZapRail.RELOAD -> @@ -2300,13 +2293,13 @@ internal fun ZapRailIcon( Material3Icon( imageVector = CustomHashTagIcons.Cashu, contentDescription = stringRes(R.string.reload_mint_title), - modifier = Modifier.size(13.dp).alpha(0.5f), + modifier = Modifier.size(cashuSize).alpha(0.5f), tint = if (colored) Color.Unspecified else mono, ) Icon( symbol = MaterialSymbols.AddCircle, contentDescription = null, - modifier = Modifier.size(9.dp), + modifier = Modifier.size(size * 0.5f), tint = if (colored) MaterialTheme.colorScheme.primary else mono, ) } @@ -2314,20 +2307,24 @@ internal fun ZapRailIcon( Icon( symbol = MaterialSymbols.Bolt, contentDescription = null, - modifier = Size18Modifier, + modifier = Modifier.size(size), tint = if (colored) BitcoinOrange else mono, ) ZapRail.ONCHAIN -> Icon( symbol = MaterialSymbols.CurrencyBitcoin, contentDescription = null, - modifier = Size18Modifier, + modifier = Modifier.size(size), tint = if (colored) BitcoinOrange else mono, ) } } -/** A circular, visibly-tappable button wrapping an alternative rail's logo. */ +/** + * A circular, tappable button wrapping an alternative rail's logo. Sized close + * to the amount's font height with a smaller icon inside, so the alternatives + * stay quiet next to the bigger, coloured preferred rail. + */ @Composable private fun RailButton( onClick: () -> Unit, @@ -2336,7 +2333,7 @@ private fun RailButton( Box( modifier = Modifier - .size(28.dp) + .size(22.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant) .clickable(onClick = onClick), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 1cca029442..50810a303a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -339,7 +339,6 @@ private fun ZapAmountPresetChip( modifier: Modifier = Modifier, ) { val rails = remember(amountInSats) { previewRailsFor(amountInSats) } - val accent = zapRailAccent(rails.first(), MaterialTheme.colorScheme.onSurface, MaterialTheme.colorScheme.primary) Surface( shape = ButtonBorder, color = MaterialTheme.colorScheme.surface, @@ -354,12 +353,12 @@ private fun ZapAmountPresetChip( Spacer(Modifier.width(5.dp)) Text( text = showAmount(amountInSats.toBigDecimal().setScale(1)), - color = accent, + color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) rails.drop(1).forEach { rail -> Spacer(Modifier.width(4.dp)) - ZapRailIcon(rail, colored = false) + ZapRailIcon(rail, colored = false, size = 14.dp) } IconButton(onClick = onRemove, modifier = Modifier.size(30.dp)) { Icon( From 3457bf439037d1e8c6ccaea0a298689577e0026a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 00:06:55 +0000 Subject: [PATCH 17/27] feat(zap chip): turn the rail picker into an animated segmented toggle (select, then send) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the "tap a rail icon = instant send" model (which read like a toggle but moved money) with an explicit two-step control, so switching rails never sends: - The rails are one connected segmented pill (shared surfaceVariant track) — they visibly belong together. A primary-container "thumb" animates to the selected segment. - Only the selected segment shows the amount (+ a send arrow); it expands in on the chosen rail and shrinks away on the previous one, so the amount reads as travelling to the icon you tapped. - Tapping an unselected rail only selects it (no payment). Tapping the selected, labelled segment is the single thing that sends. Selection starts on the amount-tier default, so the common case is still one tap. Long-press still edits the presets. Removes the old leading-icon + trailing circular-button layout and the now-unused RailButton. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../amethyst/ui/note/ReactionsRow.kt | 134 +++++++++--------- 1 file changed, 65 insertions(+), 69 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 7c9f90627c..a78780ceaf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -29,17 +29,19 @@ import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -54,7 +56,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults @@ -2170,60 +2172,77 @@ private fun UnifiedZapAmountChip( cashuReloadable -> ZapRail.RELOAD else -> ZapRail.ONCHAIN } - val others = present.filter { it != preferred } - - val defaultAction: () -> Unit = { - when (preferred) { - ZapRail.CASHU -> onNutzap(amountInSats) - ZapRail.RELOAD -> onReloadNutzap(amountInSats) - ZapRail.LIGHTNING -> onLightningZap(amountInSats) - ZapRail.ONCHAIN -> onOnchainAmount(amountInSats) - } + // The rails are one connected segmented toggle (shared surfaceVariant track). + // Tapping a rail only *selects* it — switching never moves money. The selected + // segment is the only one that shows the amount (+ a send arrow) and is the + // only thing that, when tapped, actually sends. Starts on the amount-tier + // default. + var selectedRail by remember(preferred, present) { + mutableStateOf(if (preferred in present) preferred else present.first()) } Surface( shape = ButtonBorder, - color = MaterialTheme.colorScheme.surface, + color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp), ) { Row( - modifier = - Modifier - .combinedClickable(onClick = defaultAction, onLongClick = onChangeAmount) - // With no alternative buttons on the right, match the left inset - // so the icon+amount pill stays symmetric; the trailing buttons - // supply their own right-side mass when present. - .padding(start = 10.dp, end = if (others.isEmpty()) 10.dp else 6.dp, top = 6.dp, bottom = 6.dp), + modifier = Modifier.padding(3.dp), verticalAlignment = CenterVertically, ) { - // The default is ONE tap target: the preferred rail's logo hugs the - // amount (no separate button), and a plain tap anywhere here fires - // that rail; long-press edits the presets. Only the leading logo - // carries colour — the amount itself stays neutral so it doesn't - // shout across the feed. - ZapRailIcon(preferred, colored = true) - Spacer(Modifier.width(5.dp)) - Text( - text = showAmount(amountInSats.toBigDecimal().setScale(1)), - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - ) - // Alternatives as small, quiet circular buttons (icon smaller than the - // preferred logo) to the right of the amount. - others.forEach { rail -> - Spacer(Modifier.width(6.dp)) - RailButton( - onClick = { - when (rail) { - ZapRail.CASHU -> onNutzap(amountInSats) - ZapRail.RELOAD -> onReloadNutzap(amountInSats) - ZapRail.LIGHTNING -> onLightningZap(amountInSats) - ZapRail.ONCHAIN -> onOnchainAmount(amountInSats) - } - }, + present.forEach { rail -> + val isSelected = rail == selectedRail + // The "thumb" fill slides to the selected segment (colour animates + // in/out per segment), showing both the grouping and the selection. + val thumb by animateColorAsState( + targetValue = if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, + label = "zapRailThumb", + ) + val send: () -> Unit = { + when (rail) { + ZapRail.CASHU -> onNutzap(amountInSats) + ZapRail.RELOAD -> onReloadNutzap(amountInSats) + ZapRail.LIGHTNING -> onLightningZap(amountInSats) + ZapRail.ONCHAIN -> onOnchainAmount(amountInSats) + } + } + Row( + modifier = + Modifier + .clip(RoundedCornerShape(percent = 50)) + .background(thumb) + .combinedClickable( + onClick = { if (isSelected) send() else selectedRail = rail }, + onLongClick = onChangeAmount, + ).padding(horizontal = 8.dp, vertical = 5.dp), + verticalAlignment = CenterVertically, ) { - ZapRailIcon(rail, colored = false, size = 14.dp) + ZapRailIcon(rail, colored = isSelected) + // The amount lives only on the selected segment — it expands in + // here and shrinks away on the old one, so it reads as travelling + // to the icon you picked. + AnimatedVisibility( + visible = isSelected, + enter = fadeIn() + expandHorizontally(expandFrom = Alignment.Start), + exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.Start), + ) { + Row(verticalAlignment = CenterVertically) { + Spacer(Modifier.width(5.dp)) + Text( + text = showAmount(amountInSats.toBigDecimal().setScale(1)), + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.width(3.dp)) + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowForward, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } } } } @@ -2322,29 +2341,6 @@ internal fun ZapRailIcon( } } -/** - * A circular, tappable button wrapping an alternative rail's logo. Sized close - * to the amount's font height with a smaller icon inside, so the alternatives - * stay quiet next to the bigger, coloured preferred rail. - */ -@Composable -private fun RailButton( - onClick: () -> Unit, - content: @Composable () -> Unit, -) { - Box( - modifier = - Modifier - .size(22.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable(onClick = onClick), - contentAlignment = Center, - ) { - content() - } -} - /** * Exercises the chip across rail combinations and amount bands so the default * (coloured, left of the amount) and the alternative circular buttons can be From 96b689b1cb8d11855800a71d254f99cfdcc679a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 00:18:42 +0000 Subject: [PATCH 18/27] fix(zap chip): enlarge cashu glyph (+20%) and make the selected toggle state clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The new monochrome cashu outline read too small; bump it from 0.72× to 0.86× the symbol size so it matches the bolt/bitcoin marks optically. - The selected segment's container fill sat too close to the track to read as "selected", so add an animated primary outline around the active segment — the state is now unmistakable (outline + fill + the amount label all land on it). https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../amethyst/ui/note/ReactionsRow.kt | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index a78780ceaf..f75c6edffe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -42,6 +42,7 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -2198,6 +2199,12 @@ private fun UnifiedZapAmountChip( targetValue = if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, label = "zapRailThumb", ) + // A primary outline on the selected segment so the active state is + // unmistakable even where the container fill is close to the track. + val outline by animateColorAsState( + targetValue = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent, + label = "zapRailOutline", + ) val send: () -> Unit = { when (rail) { ZapRail.CASHU -> onNutzap(amountInSats) @@ -2211,6 +2218,7 @@ private fun UnifiedZapAmountChip( Modifier .clip(RoundedCornerShape(percent = 50)) .background(thumb) + .border(1.5.dp, outline, RoundedCornerShape(percent = 50)) .combinedClickable( onClick = { if (isSelected) send() else selectedRail = rail }, onLongClick = onChangeAmount, @@ -2286,8 +2294,9 @@ internal fun previewRailsFor(amountInSats: Long): List { /** * Just the rail's logo (no click target), drawn at [size]. [colored] renders it * in its accent colour (the preferred rail); otherwise it's flattened to the - * on-surface monochrome tint. The cashu mark is drawn ~0.72× so its (now - * monochrome, tintable) cashew reads at the same optical size as the symbols. + * on-surface monochrome tint. The cashu mark is drawn ~0.86× the symbol size — + * the new monochrome outline carries less weight than the old multi-tone mark, + * so it needs less shrinking to read at a matching optical size. */ @Composable internal fun ZapRailIcon( @@ -2296,7 +2305,7 @@ internal fun ZapRailIcon( size: Dp = 18.dp, ) { val mono = MaterialTheme.colorScheme.onSurface - val cashuSize = size * 0.72f + val cashuSize = size * 0.86f when (rail) { ZapRail.CASHU -> Material3Icon( From 7bda12f047d88afd0ab80bc99dae28ebc54a5dd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 00:24:24 +0000 Subject: [PATCH 19/27] fix(zap chip): outline the whole toggle, not the selected segment Reverts the per-segment selected outline. The real ask was a clearer mark for the entire 3-rail toggle: give the whole component a 1dp `outline` border so it reads as one control. The selected segment keeps its subtle primaryContainer thumb. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../vitorpamplona/amethyst/ui/note/ReactionsRow.kt | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index f75c6edffe..45b21e625a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -40,6 +40,7 @@ import androidx.compose.animation.shrinkHorizontally import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -2185,6 +2186,9 @@ private fun UnifiedZapAmountChip( Surface( shape = ButtonBorder, color = MaterialTheme.colorScheme.surfaceVariant, + // A clear outline around the whole component so it reads as one toggle + // control rather than a few loose icons. + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp), ) { Row( @@ -2199,12 +2203,6 @@ private fun UnifiedZapAmountChip( targetValue = if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, label = "zapRailThumb", ) - // A primary outline on the selected segment so the active state is - // unmistakable even where the container fill is close to the track. - val outline by animateColorAsState( - targetValue = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent, - label = "zapRailOutline", - ) val send: () -> Unit = { when (rail) { ZapRail.CASHU -> onNutzap(amountInSats) @@ -2218,7 +2216,6 @@ private fun UnifiedZapAmountChip( Modifier .clip(RoundedCornerShape(percent = 50)) .background(thumb) - .border(1.5.dp, outline, RoundedCornerShape(percent = 50)) .combinedClickable( onClick = { if (isSelected) send() else selectedRail = rail }, onLongClick = onChangeAmount, From a1fd21d45674110d17d8c6fa972c916b51bcfb92 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 00:29:34 +0000 Subject: [PATCH 20/27] fix(zap chip): soften the toggle outline and use brand orange for selected cashu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Toggle border eased from `outline` to `outlineVariant` — present but no longer heavy. - Selected cashu (and the reload variant) now tints with the same BitcoinOrange as the Lightning/on-chain rails instead of the purple accent, so the active rail colour is consistent across all three. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../amethyst/ui/note/ReactionsRow.kt | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 45b21e625a..a9a2d55520 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -2186,9 +2186,10 @@ private fun UnifiedZapAmountChip( Surface( shape = ButtonBorder, color = MaterialTheme.colorScheme.surfaceVariant, - // A clear outline around the whole component so it reads as one toggle - // control rather than a few loose icons. - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + // A soft outline around the whole component so it reads as one toggle + // control rather than a few loose icons (outlineVariant — between a hard + // outline and none). + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp), ) { Row( @@ -2309,9 +2310,9 @@ internal fun ZapRailIcon( imageVector = CustomHashTagIcons.Cashu, contentDescription = stringRes(R.string.nutzap), modifier = Modifier.size(cashuSize), - // The cashu mark is now a monochrome, tintable outline — colour it - // with the app accent when preferred, else the mono on-surface tint. - tint = if (colored) MaterialTheme.colorScheme.primary else mono, + // The cashu mark is a monochrome, tintable outline — colour it the + // same brand orange as the other rails when selected, else mono. + tint = if (colored) BitcoinOrange else mono, ) ZapRail.RELOAD -> // Funds exist but in the wrong mint — a dimmed cashu logo with a @@ -2321,13 +2322,13 @@ internal fun ZapRailIcon( imageVector = CustomHashTagIcons.Cashu, contentDescription = stringRes(R.string.reload_mint_title), modifier = Modifier.size(cashuSize).alpha(0.5f), - tint = if (colored) MaterialTheme.colorScheme.primary else mono, + tint = if (colored) BitcoinOrange else mono, ) Icon( symbol = MaterialSymbols.AddCircle, contentDescription = null, modifier = Modifier.size(size * 0.5f), - tint = if (colored) MaterialTheme.colorScheme.primary else mono, + tint = if (colored) BitcoinOrange else mono, ) } ZapRail.LIGHTNING -> From 77c183186c236c6b8115a2cabbf11c483a6ed51c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 00:31:28 +0000 Subject: [PATCH 21/27] fix(wallet): show the cashu logo (not a generic wallet icon) on the cashu wallet row CashuWalletRow used MaterialSymbols.AccountBalanceWallet; swap it for the tintable CustomHashTagIcons.Cashu so the cashu wallet reads as cashu. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt index a3b37494d8..ad602b0f64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt @@ -68,6 +68,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.hashtags.Cashu +import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar @@ -81,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.rememberRelay import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.launch import java.text.NumberFormat +import androidx.compose.material3.Icon as Material3Icon @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -509,8 +512,8 @@ private fun CashuWalletRow( .padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - symbol = MaterialSymbols.AccountBalanceWallet, + Material3Icon( + imageVector = CustomHashTagIcons.Cashu, contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.primary, From 67d6135ee7e3ad588f3ea81a2fde0366bb873c08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 00:45:21 +0000 Subject: [PATCH 22/27] fix(topup): never re-move funds on retry; wait for proofs before zapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: a top-up that moved funds but then failed at the nutzap (the freshly minted proofs hadn't landed in local state yet → "No proofs available") left a Failed state; tapping "Try again" re-ran the WHOLE pipeline and moved the funds a second time — two transfers for one zap. - Add a `toppedUp` checkpoint set the moment funds land at the target (after rebalance / completeMintFromLightning). confirm()/retry now skips the move entirely once topped up and only (re)sends the zap — funds can never move twice. - awaitTargetFunded(): briefly poll the target balance after topping up so the follow-up nutzap sees the new proofs and succeeds on the first try instead of needing a manual retry. Best-effort with a timeout; the checkpoint guarantees no double-move even if it falls through. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../loggedIn/wallet/ReloadMintViewModel.kt | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt index 13838fcf77..7de2acdc35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt @@ -128,6 +128,11 @@ class ReloadMintViewModel : ViewModel() { * scope, not this VM's). */ private var job: Job? = null + /** Set once the target mint has actually been topped up, so a retry after a + * later failure (e.g. the send) re-sends only — it must never move funds + * again. Without this, "Try again" re-ran the whole pipeline and double-spent. */ + private var toppedUp = false + private var recipient: String? = null private val _uiState = MutableStateFlow(ReloadUiState()) @@ -278,8 +283,9 @@ class ReloadMintViewModel : ViewModel() { vm.launchSigner { try { when { - // The target already covers the send — no top-up, just zap. - moveSats <= 0L -> sendNutzapAndFinish(note, s.sendSats) + // Already topped up (or the target already covers the send) — + // never move funds again on retry, just (re)send the zap. + toppedUp || moveSats <= 0L -> sendNutzapAndFinish(note, s.sendSats) source is ReloadSource.Mint -> rebalanceThenZap(source.mintUrl, s.selectedTarget, moveSats, note, s.sendSats) source is ReloadSource.LightningWallet -> reloadFromLightningThenZap(s.selectedTarget, moveSats, walletUriFor(source.walletId), note, s.sendSats) @@ -306,6 +312,9 @@ class ReloadMintViewModel : ViewModel() { st.rebalance(sourceMint, targetMint, moveSats) { p -> setStatus(ReloadStatus.Working("Moving funds", p.coerceIn(0.1f, 0.9f))) } + // Funds have moved — checkpoint so a later failure never re-moves them. + toppedUp = true + awaitTargetFunded(targetMint, sendSats) sendNutzapAndFinish(note, sendSats) } @@ -355,9 +364,31 @@ class ReloadMintViewModel : ViewModel() { } setStatus(ReloadStatus.Working("Issuing ecash", 0.85f)) ops.completeMintFromLightning(targetMint, flow.quoteEvent, moveSats) + // Ecash minted at the target — checkpoint so a later failure never re-mints. + toppedUp = true + awaitTargetFunded(targetMint, sendSats) sendNutzapAndFinish(note, sendSats) } + /** + * Freshly moved/minted proofs reach [CashuWalletState] asynchronously (the + * kind:7375 token event round-trips before [peekMintBalances] reflects it). + * Give the target a moment to show the balance so the nutzap that follows + * doesn't fail with "No proofs available" on the first try. Best-effort: if it + * doesn't land in time the send still proceeds (and fails recoverably without + * re-moving funds, thanks to the [toppedUp] checkpoint). + */ + private suspend fun awaitTargetFunded( + targetMint: String, + sats: Long, + ) { + val st = state ?: return + repeat(12) { + if ((st.peekMintBalances()[targetMint] ?: 0L) >= sats) return + delay(500) + } + } + /** * Send the nutzap and only report [ReloadStatus.Done] once it actually succeeds. * Suspends on the real send (throwing on failure) instead of the fire-and-forget From 1e9b9fd2956214e3e67c3a6dbfe610978f0d53b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 00:45:23 +0000 Subject: [PATCH 23/27] fix(zap settings): fix chip drag (pointerInput outside the drag transform), regroup chip, header amount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drag-and-drop now works: pointerInput was nested inside the graphicsLayer translation, so the layer moving under the finger corrupted the per-frame drag deltas. Moved the gesture outside the transform. - Preset chip regrouped to match the popup: outlined track, default rail + amount in a highlighted thumb, alternatives as quiet mono icons, then the X — instead of everything mashed together. - Top-up screen header now shows the zap amount between the cashu symbol and the arrow (you → cashu · N sats → recipient). https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../amethyst/ui/note/UpdateZapAmountDialog.kt | 55 +++++++++++++------ .../loggedIn/wallet/ReloadMintScreen.kt | 8 +++ amethyst/src/main/res/values/strings.xml | 1 + 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 50810a303a..d20cdb729d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -32,6 +32,7 @@ import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -47,6 +48,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon @@ -64,6 +66,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.graphicsLayer @@ -198,13 +201,11 @@ fun UpdateZapAmountContent( modifier = Modifier .onGloballyPositioned { itemBounds[amountInSats] = it.boundsInParent() } - .zIndex(if (dragging) 1f else 0f) - .graphicsLayer { - if (dragging) { - translationX = dragOffset.x - translationY = dragOffset.y - } - }.pointerInput(amountInSats) { + // pointerInput must sit OUTSIDE the graphicsLayer + // translation below — otherwise the layer moving under + // the finger distorts the per-frame drag deltas and the + // drag runs away / never settles. + .pointerInput(amountInSats) { detectDragGesturesAfterLongPress( onDragStart = { draggingAmount = amountInSats @@ -239,6 +240,12 @@ fun UpdateZapAmountContent( } }, ) + }.zIndex(if (dragging) 1f else 0f) + .graphicsLayer { + if (dragging) { + translationX = dragOffset.x + translationY = dragOffset.y + } }, ) } @@ -339,28 +346,40 @@ private fun ZapAmountPresetChip( modifier: Modifier = Modifier, ) { val rails = remember(amountInSats) { previewRailsFor(amountInSats) } + // Mirrors the popup: one outlined track, the default rail + amount grouped in a + // highlighted "thumb", the alternatives as quiet mono icons, then the remove X. Surface( shape = ButtonBorder, - color = MaterialTheme.colorScheme.surface, + color = MaterialTheme.colorScheme.surfaceVariant, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), modifier = modifier, ) { Row( - modifier = Modifier.padding(start = 10.dp, end = 2.dp, top = 2.dp, bottom = 2.dp), + modifier = Modifier.padding(start = 4.dp, end = 2.dp, top = 3.dp, bottom = 3.dp), verticalAlignment = Alignment.CenterVertically, ) { - ZapRailIcon(rails.first(), colored = true) - Spacer(Modifier.width(5.dp)) - Text( - text = showAmount(amountInSats.toBigDecimal().setScale(1)), - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) + Row( + modifier = + Modifier + .clip(RoundedCornerShape(percent = 50)) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ZapRailIcon(rails.first(), colored = true) + Spacer(Modifier.width(5.dp)) + Text( + text = showAmount(amountInSats.toBigDecimal().setScale(1)), + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.SemiBold, + ) + } rails.drop(1).forEach { rail -> - Spacer(Modifier.width(4.dp)) + Spacer(Modifier.width(8.dp)) ZapRailIcon(rail, colored = false, size = 14.dp) } - IconButton(onClick = onRemove, modifier = Modifier.size(30.dp)) { + Spacer(Modifier.width(4.dp)) + IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) { Icon( symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.remove), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt index 0163b90951..51dc439c52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt @@ -184,6 +184,14 @@ fun ReloadMintScreen( tint = MaterialTheme.colorScheme.primary, ) Spacer(Modifier.width(6.dp)) + // The amount we're about to zap, shown along the flow. + Text( + text = stringRes(R.string.reload_mint_sats_amount, sats(ui.sendSats)), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(6.dp)) Icon( symbol = MaterialSymbols.AutoMirrored.ArrowForward, contentDescription = null, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5b31865bc3..01d3b1c9ba 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -879,6 +879,7 @@ Shown when pressing the zap button. Each amount can be paid by any rail the recipient supports — Lightning, Cashu, or on-chain (on-chain only for larger amounts). Tap an amount to remove it. If you leave it empty, it will open the dialog to insert an amount every time. Send on-chain instead Top up mint + %1$s sats Top up amount Mint to top up Funds from From 2c02795e453a61953769110bbe9cce43436652ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 01:02:31 +0000 Subject: [PATCH 24/27] feat(zap popup): react to lnAddress + cashu/nutzap info loading so rails appear live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zap popup computed rail availability once from a synchronous snapshot, so a recipient whose lnAddress (kind:0) or nutzap info (kind:10019) hadn't loaded yet — or before our own cashu wallet finished loading — showed no Lightning/cashu logo and never updated. Now it observes those inputs and recomputes railCapability as they arrive: - observeUserInfo(author) → the Lightning logo appears when the lnAddress loads. - observeNoteEvent(author.nutzapInfoNote) + the cashu wallet's mints/tokenEntries flows → the cashu logo appears when the recipient's kind:10019 and our proofs load. The observers also trigger the relay fetch, so a not-yet-seen lnAddress / kind:10019 gets pulled in while the popup is open. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../amethyst/ui/note/ReactionsRow.kt | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index a9a2d55520..e2e7f89962 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -136,6 +136,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteRepostsBy import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.uploads.FloatingRecordingIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS @@ -195,6 +196,7 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList @@ -1951,13 +1953,23 @@ fun ZapAmountChoicePopup( // [railCapability] rather than by keeping separate per-rail lists. // // [railCapability] gates which rails the recipient can receive on (plus the - // sender's cashu balance, which is free to read). It's recomputed only when - // the note changes — the underlying cache/wallet flows are stable for the - // ~1s the popup is open. A caller that can't drive the on-chain dialog - // (onchainSupported == false) masks that rail off here. + // sender's cashu balance). The inputs load asynchronously, so observe them and + // recompute as they arrive — the chip's Lightning logo appears once the + // recipient's lnAddress (kind:0) loads, and the cashu logo once their nutzap + // info (kind:10019) and our cashu wallet (mints + proofs) load. The observers + // also trigger the relay fetch, so a not-yet-seen lnAddress / kind:10019 is + // pulled in while the popup is open. A caller that can't drive the on-chain + // dialog (onchainSupported == false) masks that rail off here. + val cashuState = accountViewModel.account.cashuWalletState + val author = baseNote.author + val cashuMints by cashuState.mints.collectAsStateWithLifecycle() + val cashuEntries by cashuState.tokenEntries.collectAsStateWithLifecycle() + val recipientInfo = author?.let { observeUserInfo(it, accountViewModel).value } + val nutzapInfo = author?.let { observeNoteEvent(it.nutzapInfoNote, accountViewModel).value } + val railCapability = - remember(baseNote, onchainSupported) { - val rc = RailCapabilityResolver.peek(baseNote, accountViewModel.account.cashuWalletState) + remember(baseNote, onchainSupported, cashuMints, cashuEntries, recipientInfo, nutzapInfo) { + val rc = RailCapabilityResolver.peek(baseNote, cashuState) if (onchainSupported) rc else rc.copy(hasOnchain = false) } val amountChoices = From 380122bcb5b70d620568221e626befca6bf62c5c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 02:07:43 +0000 Subject: [PATCH 25/27] fix(topup): checkpoint the instant funds leave the wallet, not after the whole flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit found the toppedUp checkpoint was still too coarse: it was set only AFTER rebalance() / the LN mint fully returned. But funds leave the wallet mid-flow — the source melt (rebalance) and the invoice payment (Lightning) both happen before the poll/completeMintFromLightning steps that can throw. A failure there left toppedUp=false, so "Try again" re-ran the whole move and spent a second time. - CashuWalletState.rebalance gains an onFundsMoved callback fired immediately after the melt succeeds; the VM sets toppedUp there. - The Lightning path sets toppedUp the moment the invoice is confirmed paid, before issuing ecash. Either way, once money has moved a retry can only re-send / resume — it can never move funds again. (The paid-but-unissued quote remains recoverable via the pending quote banner.) https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../model/nip60Cashu/CashuWalletState.kt | 6 ++++++ .../loggedIn/wallet/ReloadMintViewModel.kt | 21 ++++++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 9218037079..902ca796d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -1179,6 +1179,7 @@ class CashuWalletState( targetMintUrl: String, sats: Long, onProgress: ((Float) -> Unit)? = null, + onFundsMoved: () -> Unit = {}, ): RebalanceCompleted { check(started) { "CashuWalletState.start() not called" } require(sats > 0) { "Amount must be positive" } @@ -1207,6 +1208,11 @@ class CashuWalletState( // 3. Pay the destination invoice by melting at the source. onProgress?.invoke(0.5f) meltToLightning(sourceMintUrl, meltQuote) + // Funds have now LEFT the source. Signal the caller immediately so a + // failure in the steps below (slow confirmation, completeMint error) + // never causes a retry to melt a second time — the destination quote is + // paid and resumable via the pending-quote banner instead. + onFundsMoved() // 4. The melt paid the invoice; confirm + issue proofs at the target. // The melt settled synchronously, but a healthy mint can still lag a diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt index 7de2acdc35..477adc4af4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt @@ -309,11 +309,16 @@ class ReloadMintViewModel : ViewModel() { ) { val st = state ?: return setStatus(ReloadStatus.Working("Moving funds", 0.1f)) - st.rebalance(sourceMint, targetMint, moveSats) { p -> - setStatus(ReloadStatus.Working("Moving funds", p.coerceIn(0.1f, 0.9f))) - } - // Funds have moved — checkpoint so a later failure never re-moves them. - toppedUp = true + st.rebalance( + sourceMintUrl = sourceMint, + targetMintUrl = targetMint, + sats = moveSats, + onProgress = { p -> setStatus(ReloadStatus.Working("Moving funds", p.coerceIn(0.1f, 0.9f))) }, + // Checkpoint the instant funds leave the source — not after the whole + // rebalance returns. A failure between the melt and the issue/send steps + // must never let a retry melt again. + onFundsMoved = { toppedUp = true }, + ) awaitTargetFunded(targetMint, sendSats) sendNutzapAndFinish(note, sendSats) } @@ -362,10 +367,12 @@ class ReloadMintViewModel : ViewModel() { setStatus(ReloadStatus.Failed("Invoice not paid yet — you can finish it later from the pending quote banner")) return } + // The invoice is paid — money has left the wallet. Checkpoint now, before + // issuing, so a failure in completeMintFromLightning never lets a retry + // mint+pay a second time (the paid quote is resumable from the banner). + toppedUp = true setStatus(ReloadStatus.Working("Issuing ecash", 0.85f)) ops.completeMintFromLightning(targetMint, flow.quoteEvent, moveSats) - // Ecash minted at the target — checkpoint so a later failure never re-mints. - toppedUp = true awaitTargetFunded(targetMint, sendSats) sendNutzapAndFinish(note, sendSats) } From 93648085f972d6eb6b538d36827524b6c7ddece1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 02:16:39 +0000 Subject: [PATCH 26/27] fix(zap settings): de-dupe preset amounts so drag-reorder + chip keys stay valid addAmount() allowed adding the same amount twice; two preset chips then share the same key(amount) (Compose duplicate-key hazard) and the drag-reorder's indexOf(amount) resolves to the wrong chip. De-dupe on add. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../amethyst/ui/note/UpdateZapAmountViewModel.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt index 76d97fa4c9..0a5931f26b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt @@ -72,7 +72,10 @@ class UpdateZapAmountViewModel : ViewModel() { fun addAmount() { val newValue = nextAmount.text.trim().toLongOrNull() - if (newValue != null) { + // De-dupe: a repeated amount would give two preset chips the same key (a + // Compose duplicate-key hazard) and make the drag-reorder's indexOf() + // resolve to the wrong chip. + if (newValue != null && newValue !in amountSet) { amountSet = amountSet + newValue } From d6640be62c1db59e20e3a7360db5db105603b75b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 14:13:26 +0000 Subject: [PATCH 27/27] =?UTF-8?q?refactor(topup/zap):=20address=20audit=20?= =?UTF-8?q?cleanups=20=E2=80=94=20pin=20send=20mint,=20dedupe=20scrub,=20s?= =?UTF-8?q?hare=20clipboard,=20scope=20rebuilds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2 — sendNutzap gains an optional preferredMintUrl; the Top-up screen passes the just-funded selectedTarget so the nutzap spends from THAT mint instead of whichever shared mint holds the most (which could leave the top-up sitting idle). Falls back to the best-balance pick when the preferred mint isn't a valid shared target. R5 — meltToLightning gains skipScrub; rebalance() (which already scrubbed the source for its coverage check) passes it to drop the redundant second NUT-07 /checkstate round-trip. The Top-up screen's wallet collector now projects to the per-mint balance map + distinctUntilChanged, so unrelated wallet activity (an inbound redeem, a scrub, a token for another mint) no longer re-runs the whole balances/targets/sources rebuild on every global tokenEntries emission. R7 — replace ReloadMintScreen's hand-rolled copyToClipboard with the shared Clipboard.setText helper (drops the android ClipData/ClipboardManager imports); document the keys-only observed values in the reactive railCapability block so a future reader doesn't delete them as "unused" and silently break the live rail loading + relay fetch. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../model/nip60Cashu/CashuWalletState.kt | 33 ++++++++++++++++--- .../amethyst/ui/note/ReactionsRow.kt | 6 ++++ .../loggedIn/wallet/ReloadMintScreen.kt | 21 ++++-------- .../loggedIn/wallet/ReloadMintViewModel.kt | 25 +++++++++----- 4 files changed, 59 insertions(+), 26 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 902ca796d8..8aed359088 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -763,6 +763,16 @@ class CashuWalletState( */ fun peekNutzapTarget(recipientPubKey: HexKey): NutzapTarget? = peekNutzapFunding(recipientPubKey)?.target + /** True if [mintUrl] is a mint we hold AND the recipient accepts nutzaps on. */ + fun sharedNutzapMint( + recipientPubKey: HexKey, + mintUrl: String, + ): Boolean { + if (mintUrl !in _mints.value) return false + val info = cache.getOrCreateUser(recipientPubKey).nutzapInfo() ?: return false + return info.mints().any { it.mintUrl == mintUrl } + } + /** * Resolve nutzap funding for [recipientPubKey]: the best shared mint to * spend from plus the balance figures the zap picker needs to classify @@ -1078,12 +1088,22 @@ class CashuWalletState( recipientPubKey: HexKey, zappedEvent: EventHintBundle, message: String = "", + preferredMintUrl: String? = null, onProgress: ((Float) -> Unit)? = null, ): NutzapSent { check(started) { "CashuWalletState.start() not called" } - val target = + val resolved = peekNutzapTarget(recipientPubKey) ?: throw IllegalStateException("Recipient does not accept nutzaps from any of our mints") + // Honor an explicit mint when the caller has one in mind (e.g. the Top-up + // screen just funded a specific mint and must spend from THAT one, not + // whichever shared mint happens to hold the most). Only if it's still a + // valid shared target; otherwise fall back to the best-balance pick. + val target = + preferredMintUrl + ?.takeIf { it == resolved.mintUrl || sharedNutzapMint(recipientPubKey, it) } + ?.let { NutzapTarget(mintUrl = it, recipientP2pkPubkeyHex = resolved.recipientP2pkPubkeyHex) } + ?: resolved // NUT-07 check + immediate state cleanup before selecting. // The startup scrub can't catch entries that arrive from relays @@ -1146,11 +1166,14 @@ class CashuWalletState( suspend fun meltToLightning( mintUrl: String, quote: MeltQuoteBolt11ResponseDto, + skipScrub: Boolean = false, ): MeltCompleted { check(started) { "CashuWalletState.start() not called" } if (mintUrl.isBlank()) throw IllegalArgumentException("Pick a mint") - scrubLocallyStaleProofs(mintUrl) + // [rebalance] already scrubbed this mint to compute its coverage check, so + // it passes skipScrub=true to avoid a second NUT-07 /checkstate round-trip. + if (!skipScrub) scrubLocallyStaleProofs(mintUrl) val available = _tokenEntries.value.filter { it.content.mint == mintUrl } if (available.isEmpty()) { @@ -1205,9 +1228,11 @@ class CashuWalletState( throw IllegalStateException("$sourceMintUrl has $sourceBalance sat — needs $required to move $sats") } - // 3. Pay the destination invoice by melting at the source. + // 3. Pay the destination invoice by melting at the source. We already + // scrubbed sourceMintUrl above for the coverage check, so skip the + // redundant second scrub inside meltToLightning. onProgress?.invoke(0.5f) - meltToLightning(sourceMintUrl, meltQuote) + meltToLightning(sourceMintUrl, meltQuote, skipScrub = true) // Funds have now LEFT the source. Signal the caller immediately so a // failure in the steps below (slow confirmation, completeMint error) // never causes a retry to melt a second time — the destination quote is diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index e2e7f89962..2e352ab051 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -1962,6 +1962,12 @@ fun ZapAmountChoicePopup( // dialog (onchainSupported == false) masks that rail off here. val cashuState = accountViewModel.account.cashuWalletState val author = baseNote.author + // These four are deliberately read only to drive the recompute below — do NOT + // delete them as "unused". Each observe* call ALSO subscribes the relay fetch + // (so a not-yet-seen kind:0 / kind:10019 gets pulled in while the popup is + // open), and each value is a remember() key so railCapability recomputes when + // it arrives. RailCapabilityResolver.peek re-reads everything itself; these + // just say *when* to re-run it. val cashuMints by cashuState.mints.collectAsStateWithLifecycle() val cashuEntries by cashuState.tokenEntries.collectAsStateWithLifecycle() val recipientInfo = author?.let { observeUserInfo(it, accountViewModel).value } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt index 51dc439c52..5aef6bdd64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt @@ -20,9 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -58,10 +55,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf 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.LocalContext +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow @@ -74,6 +72,7 @@ import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.Note +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 @@ -81,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.launch import java.util.UUID import androidx.compose.material3.Icon as Material3Icon @@ -122,7 +122,8 @@ fun ReloadMintScreen( LaunchedEffect(requestId) { viewModel.init(accountViewModel, request) } val ui by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() // Local source-of-truth for the editable top-up field. The send amount is // fixed; the top-up defaults to the shortfall the VM computes once the wallet @@ -320,7 +321,7 @@ fun ReloadMintScreen( Text(stringRes(R.string.reload_mint_awaiting_payment), style = MaterialTheme.typography.bodyMedium) } OutlinedButton( - onClick = { copyToClipboard(context, status.invoice) }, + onClick = { scope.launch { clipboard.setText(status.invoice) } }, modifier = Modifier.fillMaxWidth(), ) { Text(stringRes(R.string.reload_mint_copy_invoice)) @@ -439,11 +440,3 @@ private fun SourceRow( } } } - -private fun copyToClipboard( - context: Context, - text: String, -) { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("invoice", text)) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt index 477adc4af4..3fcb017ec3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -161,18 +162,22 @@ class ReloadMintViewModel : ViewModel() { // Wallet proofs/mints arrive from relays and can land *after* the screen // opens — a one-time snapshot would show only the first-loaded mint. Keep - // balances/targets in sync as the wallet fills in. + // balances/targets in sync as the wallet fills in, but project to just the + // per-mint balance map and distinctUntilChanged so unrelated wallet churn + // (an inbound nutzap redeem, a scrub, a token for some other mint) doesn't + // re-run the whole rebuild on every global tokenEntries emission. viewModelScope.launch { - combine(st.tokenEntries, st.mints) { _, _ -> Unit }.collect { rebuild() } + combine(st.tokenEntries, st.mints) { _, _ -> st.peekMintBalances() } + .distinctUntilChanged() + .collect { rebuild(it) } } } /** Rebuild balances/targets/sources from the current wallet state, keeping the * user's chosen target, top-up, source pick, and status intact. */ - private fun rebuild() { - val st = state ?: return + private fun rebuild(balances: Map) { val recipient = recipient ?: return - val balances = st.peekMintBalances() + val st = state ?: return val targets = st.recipientSharedMints(recipient).sortedByDescending { balances[it] ?: 0L } _uiState.update { cur -> val target = cur.selectedTarget.takeIf { it in targets } ?: targets.firstOrNull().orEmpty() @@ -285,7 +290,7 @@ class ReloadMintViewModel : ViewModel() { when { // Already topped up (or the target already covers the send) — // never move funds again on retry, just (re)send the zap. - toppedUp || moveSats <= 0L -> sendNutzapAndFinish(note, s.sendSats) + toppedUp || moveSats <= 0L -> sendNutzapAndFinish(note, s.sendSats, s.selectedTarget) source is ReloadSource.Mint -> rebalanceThenZap(source.mintUrl, s.selectedTarget, moveSats, note, s.sendSats) source is ReloadSource.LightningWallet -> reloadFromLightningThenZap(s.selectedTarget, moveSats, walletUriFor(source.walletId), note, s.sendSats) @@ -320,7 +325,7 @@ class ReloadMintViewModel : ViewModel() { onFundsMoved = { toppedUp = true }, ) awaitTargetFunded(targetMint, sendSats) - sendNutzapAndFinish(note, sendSats) + sendNutzapAndFinish(note, sendSats, targetMint) } private suspend fun reloadFromLightningThenZap( @@ -374,7 +379,7 @@ class ReloadMintViewModel : ViewModel() { setStatus(ReloadStatus.Working("Issuing ecash", 0.85f)) ops.completeMintFromLightning(targetMint, flow.quoteEvent, moveSats) awaitTargetFunded(targetMint, sendSats) - sendNutzapAndFinish(note, sendSats) + sendNutzapAndFinish(note, sendSats, targetMint) } /** @@ -406,6 +411,7 @@ class ReloadMintViewModel : ViewModel() { private suspend fun sendNutzapAndFinish( note: Note, sendSats: Long, + targetMint: String, ) { val st = state ?: return val recipient = note.author?.pubkeyHex ?: throw IllegalStateException("Recipient has no pubkey") @@ -416,6 +422,9 @@ class ReloadMintViewModel : ViewModel() { recipientPubKey = recipient, zappedEvent = zappedEvent, message = "", + // Spend from the mint we just topped up, not whichever shared mint + // happens to hold the most — otherwise the top-up sits idle. + preferredMintUrl = targetMint.takeIf { it.isNotBlank() }, ) setStatus(ReloadStatus.Done) }