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..f10791a1d5 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() + @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..bdc1ab134e 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,23 @@ 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 + +/** + * 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, @@ -154,6 +171,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/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 4e6f296435..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 @@ -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 @@ -760,25 +761,91 @@ 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 + + /** 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 + * 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() }, ) } + /** + * 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 @@ -1021,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 @@ -1089,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()) { @@ -1102,6 +1182,96 @@ 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, + onFundsMoved: () -> Unit = {}, + ): 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. We already + // scrubbed sourceMintUrl above for the coverage check, so skip the + // redundant second scrub inside meltToLightning. + onProgress?.invoke(0.5f) + 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 + // 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 + // 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 = 30 + val pollDelayMs = 2_000L + var paid = false + var attempt = 0 + while (!paid && attempt < pollAttempts) { + paid = ops.checkMintQuote(targetMintUrl, mintFlow.mintQuote.quote).isSettled() + if (!paid) { + delay(pollDelayMs) + 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 // ============================================================ @@ -1133,3 +1303,24 @@ 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, +) + +/** Result of a [CashuWalletState.rebalance] mint-to-mint move. */ +data class RebalanceCompleted( + val sourceMintUrl: String, + val targetMintUrl: String, + val movedSats: Long, + val feeSats: 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..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 @@ -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,18 +88,19 @@ 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 * 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, @@ -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/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/components/ReusableZapButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt index 0a594d4131..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 @@ -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 @@ -61,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 @@ -110,10 +110,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,16 +197,15 @@ fun ReusableZapButton( nav.nav(Route.ManualZapSplitPayment(uid)) } }, - onchainZapAmountChoices = onchainZapAmountChoices, onOnchainAmount = { amount -> wantsToZap = null onchainZapAmount = amount showOnchainDialog = true }, - nutzapEnabled = - baseNote.author?.pubkeyHex?.let { recipient -> - accountViewModel.account.cashuWalletState.peekNutzapTarget(recipient) != null - } ?: false, + 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 e382189a5b..218312863a 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 @@ -200,6 +200,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 @@ -311,7 +312,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) } @@ -345,6 +346,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 7662d74b39..02dcc6c61c 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 @@ -204,7 +204,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() @@ -643,6 +645,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 ae922d8403..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 @@ -29,15 +29,21 @@ 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.BorderStroke 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 @@ -52,6 +58,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.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults @@ -83,6 +90,8 @@ 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 import androidx.compose.ui.platform.LocalContext @@ -107,10 +116,13 @@ 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 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 @@ -124,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 @@ -140,6 +153,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 @@ -182,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 @@ -1251,6 +1266,10 @@ fun ZapReaction( nav.nav(Route.ManualZapSplitPayment(uid)) } }, + onReloadNutzap = { amount -> + wantsToZap = false + navigateToReloadMint(accountViewModel, nav, baseNote, amount) + }, ) } @@ -1375,17 +1394,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 -> { @@ -1879,29 +1907,15 @@ 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 .collectAsStateWithLifecycle() - val onchainZapAmountChoices by - 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 +1924,9 @@ fun ZapAmountChoicePopup( onError = onError, onProgress = onProgress, onPayViaIntent = onPayViaIntent, - onchainZapAmountChoices = - if (onOnchainAmount != null && railCapability.hasOnchain) { - onchainZapAmountChoices - } else { - persistentListOf() - }, onOnchainAmount = onOnchainAmount ?: {}, - nutzapEnabled = railCapability.hasCashu, + onchainSupported = onOnchainAmount != null, + onReloadNutzap = onReloadNutzap, ) } @@ -1933,15 +1942,53 @@ fun ZapAmountChoicePopup( onError: (title: String, text: String, user: User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, - onchainZapAmountChoices: ImmutableList = persistentListOf(), onOnchainAmount: (Long?) -> Unit = {}, - nutzapEnabled: Boolean = false, + 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 + // 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). 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 + // 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 } + val nutzapInfo = author?.let { observeNoteEvent(it.nutzapInfoNote, accountViewModel).value } + + val railCapability = + remember(baseNote, onchainSupported, cashuMints, cashuEntries, recipientInfo, nutzapInfo) { + val rc = RailCapabilityResolver.peek(baseNote, cashuState) + if (onchainSupported) rc else rc.copy(hasOnchain = false) + } + val amountChoices = + remember(zapAmountChoices) { + // 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( baseNote, - zapAmountChoices, - onchainZapAmountChoices, + amountChoices, + railCapability, accountViewModel, popupYOffset, visibilityState, @@ -1951,7 +1998,7 @@ fun ZapAmountChoicePopup( onError, onProgress, onPayViaIntent, - nutzapEnabled, + onReloadNutzap, ) } @@ -1959,8 +2006,8 @@ fun ZapAmountChoicePopup( @Composable fun ZapAmountChoicePopup( baseNote: Note, - zapAmountChoices: ImmutableList, - onchainZapAmountChoices: ImmutableList, + amountChoices: ImmutableList, + railCapability: RailCapability, accountViewModel: AccountViewModel, popupYOffset: Dp, visibilityState: MutableTransitionState, @@ -1970,7 +2017,7 @@ fun ZapAmountChoicePopup( onError: (title: String, text: String, user: User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, - nutzapEnabled: Boolean = false, + onReloadNutzap: (Long) -> Unit = {}, ) { val context = LocalContext.current val yOffset = with(LocalDensity.current) { -popupYOffset.toPx().toInt() } @@ -1987,9 +2034,9 @@ fun ZapAmountChoicePopup( exit = popupAnimationExit, ) { ZapAmountChoicePopupContent( - zapAmountChoices = zapAmountChoices, - onchainZapAmountChoices = onchainZapAmountChoices, - onZap = { amountInSats -> + amountChoices = amountChoices, + railCapability = railCapability, + onLightningZap = { amountInSats -> onZapStarts() accountViewModel.zap( baseNote, @@ -2004,12 +2051,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 +2066,15 @@ fun ZapAmountChoicePopup( ) visibilityState.targetState = false }, + onOnchainAmount = { amount -> + onOnchainAmount(amount) + visibilityState.targetState = false + }, + onReloadNutzap = { amount -> + onReloadNutzap(amount) + visibilityState.targetState = false + }, + onChangeAmount = onChangeAmount, ) } } @@ -2033,13 +2083,13 @@ 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 = {}, + onReloadNutzap: (Long) -> Unit = {}, ) { Box(HalfPadding, contentAlignment = Center) { ElevatedCard( @@ -2053,29 +2103,15 @@ 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, + onReloadNutzap = onReloadNutzap, + onChangeAmount = onChangeAmount, ) } ClickableBox( @@ -2098,125 +2134,290 @@ fun ZapAmountChoicePopupContent( } } +/** + * One pill per amount, showing a tappable logo for every rail that can pay it: + * - **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 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 -private fun NutzapAmountChip( +private fun UnifiedZapAmountChip( amountInSats: Long, - onClick: () -> Unit, - onLongClick: () -> Unit, + railCapability: RailCapability, + onLightningZap: (Long) -> Unit, + onNutzap: (Long) -> Unit, + onOnchainAmount: (Long?) -> Unit, + onReloadNutzap: (Long) -> Unit, + onChangeAmount: () -> Unit, ) { + 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 && !cashuReloadable && !lightningReady && !onchainReady) return + + // 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 + } + // 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.tertiary, + color = MaterialTheme.colorScheme.surfaceVariant, + // 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( - modifier = - Modifier - .combinedClickable(onClick = onClick, onLongClick = onLongClick) - .padding(horizontal = 12.dp, vertical = 6.dp), + modifier = Modifier.padding(3.dp), verticalAlignment = CenterVertically, ) { + 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 = 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, + ) + } + } + } + } + } + } +} + +/** Payment rails a zap-amount chip can offer, in canonical display order. */ +internal enum class ZapRail { CASHU, RELOAD, LIGHTNING, ONCHAIN } + +/** Below this, the default rail is cashu (Lightning min/fees make tiny zaps awkward). */ +internal const val CASHU_PREFERRED_BELOW_SATS = 10L + +/** Above this, the default rail is an on-chain transaction. */ +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 } +} + +/** + * 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.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( + rail: ZapRail, + colored: Boolean, + size: Dp = 18.dp, +) { + val mono = MaterialTheme.colorScheme.onSurface + val cashuSize = size * 0.86f + when (rail) { + ZapRail.CASHU -> Material3Icon( imageVector = CustomHashTagIcons.Cashu, contentDescription = stringRes(R.string.nutzap), - modifier = Size18Modifier, + modifier = Modifier.size(cashuSize), + // 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, ) - Spacer(Modifier.width(2.dp)) - Text( - text = showAmount(amountInSats.toBigDecimal().setScale(1)), - color = MaterialTheme.colorScheme.onTertiary, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - ) - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun ZapAmountChip( - 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, - ) { + 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.reload_mint_title), + modifier = Modifier.size(cashuSize).alpha(0.5f), + tint = if (colored) BitcoinOrange else mono, + ) + Icon( + symbol = MaterialSymbols.AddCircle, + contentDescription = null, + modifier = Modifier.size(size * 0.5f), + tint = if (colored) BitcoinOrange else mono, + ) + } + ZapRail.LIGHTNING -> Icon( symbol = MaterialSymbols.Bolt, contentDescription = null, - modifier = Size18Modifier, - tint = Color.White, + modifier = Modifier.size(size), + tint = if (colored) BitcoinOrange else mono, ) - 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, - ) { + ZapRail.ONCHAIN -> Icon( symbol = MaterialSymbols.CurrencyBitcoin, contentDescription = null, - modifier = Size18Modifier, - tint = Color.White, + modifier = Modifier.size(size), + tint = if (colored) BitcoinOrange else mono, ) - Spacer(Modifier.width(2.dp)) - Text( - text = showAmount(amountInSats.toBigDecimal().setScale(1)), - color = Color.White, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, + } +} + +/** + * 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 { + 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, ) } } } -@Preview @Composable -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 = {}, - onOnchainAmount = {}, - ) - } +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 { 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..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 @@ -20,30 +20,20 @@ */ 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.clickable +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 import androidx.compose.foundation.layout.ColumnScope @@ -58,58 +48,51 @@ 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.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 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.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.input.ImeAction +import androidx.compose.ui.draw.clip +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.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.compose.ui.zIndex 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.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 @@ -138,15 +121,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( @@ -176,36 +153,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 @@ -229,8 +176,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 @@ -240,28 +193,62 @@ 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, - ), - ) + key(amountInSats) { + val dragging = draggingAmount == amountInSats + ZapAmountPresetChip( + amountInSats = amountInSats, + onRemove = { postViewModel.removeAmount(amountInSats) }, + modifier = + Modifier + .onGloballyPositioned { itemBounds[amountInSats] = it.boundsInParent() } + // 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 + 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 + } + }, + ) + }.zIndex(if (dragging) 1f else 0f) + .graphicsLayer { + if (dragging) { + translationX = dragOffset.x + translationY = dragOffset.y + } + }, + ) + } } } @@ -339,404 +326,71 @@ 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 ─────────────────────────────────── - - 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)) } } +/** + * 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, + 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.surfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = modifier, + ) { + Row( + modifier = Modifier.padding(start = 4.dp, end = 2.dp, top = 3.dp, bottom = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + 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(8.dp)) + ZapRailIcon(rail, colored = false, size = 14.dp) + } + Spacer(Modifier.width(4.dp)) + IconButton(onClick = onRemove, modifier = Modifier.size(28.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, 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..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 @@ -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() @@ -75,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 } @@ -86,17 +86,16 @@ 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 + /** 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() { @@ -132,15 +131,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 +145,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..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. @@ -1297,10 +1301,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/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/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, 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 000ca33990..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,7 +73,9 @@ 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 import com.vitorpamplona.amethyst.ui.components.namecoin.NamecoinResolutionRow import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav @@ -99,13 +101,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 +208,17 @@ 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() + // 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/ReloadMintScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt new file mode 100644 index 0000000000..5aef6bdd64 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt @@ -0,0 +1,442 @@ +/* + * 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.foundation.BorderStroke +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.layout.size +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 +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.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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.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 +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.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.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 + +/** + * 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 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 + // 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() } + } + + 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), + ) { + // ── 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 = 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, + 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, + ) + } + + // ── 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)) + 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) }, + ) + } + } + } + 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 = + 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, + ) + } + } + + // ── 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) }, + ) + } + } + + Spacer(Modifier.height(4.dp)) + + // ── 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_summary, + sats(maxOf(ui.topUpSats, ui.shortfallSats)), + shortMint(ui.selectedTarget), + recipientName, + sats(ui.sendSats), + sats(ui.estFeeSats), + ) + } else { + stringRes(R.string.reload_mint_summary_funded, recipientName, sats(ui.sendSats), shortMint(ui.selectedTarget)) + }, + 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 = { scope.launch { clipboard.setText(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 = + 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, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(if (ui.shortfallSats > 0) R.string.reload_mint_confirm else R.string.reload_mint_send_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 = + when (source) { + is ReloadSource.Mint -> source.canCover + is ReloadSource.LightningWallet -> true + ReloadSource.LightningExternal -> true + } + val title = + when (source) { + is ReloadSource.Mint -> shortMint(source.mintUrl) + 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. + val subtitle = + when (source) { + is ReloadSource.Mint -> + if (source.canCover) { + stringRes(R.string.reload_mint_available, sats(source.balanceSats)) + } else { + stringRes(R.string.reload_mint_not_enough) + } + 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.primary.copy(alpha = 0.08f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + border = if (selected) BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)) else null, + modifier = + Modifier + .fillMaxWidth() + .selectable(selected = selected, enabled = enabled, onClick = onSelect), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 14.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + 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 = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + 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 new file mode 100644 index 0000000000..3fcb017ec3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt @@ -0,0 +1,488 @@ +/* + * 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 androidx.lifecycle.viewModelScope +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.Nip47WalletConnect +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 +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** 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 top-up 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 + + /** 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. */ +@Immutable +data class MintBalance( + val mintUrl: String, + val balanceSats: Long, +) + +/** Progress of the top-up + send. */ +@Immutable +sealed interface ReloadStatus { + data object Configuring : ReloadStatus + + data class Working( + val step: String, + val progress: Float, + ) : ReloadStatus + + /** External Lightning — the user must pay [invoice]; 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 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(), + 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 + + /** 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 + + /** 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()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun init( + 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, + 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 + // 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) { _, _ -> 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(balances: Map) { + val recipient = recipient ?: return + 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() + val shortfall = (cur.sendSats - (balances[target] ?: 0L)).coerceAtLeast(0L) + recompute( + cur.copy( + balances = + balances.entries + .sortedByDescending { it.value } + .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, 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.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 = moveSats <= 0L || it.balanceSats >= needFromSource) } + + 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.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 + ?: mintSources.filter { it.canCover }.maxByOrNull { it.balanceSats } + ?: defaultLightningSource(lightningSources) + + return base.copy( + selectedTarget = target, + shortfallSats = shortfall, + estFeeSats = estFee, + sources = sources.toImmutableList(), + selectedSource = selectedSource, + ) + } + + fun selectTarget(mintUrl: String) { + _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) } + } + + /** 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() { + val vm = accountViewModel ?: return + val note = baseNote ?: return + val s = _uiState.value + val source = s.selectedSource ?: 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. + if (s.status !is ReloadStatus.Configuring && s.status !is ReloadStatus.Failed) return + setStatus(ReloadStatus.Working("Starting", 0.05f)) + + val moveSats = moveSatsFor(s.topUpSats, s.shortfallSats) + + job?.cancel() + job = + vm.launchSigner { + try { + 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, 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) + 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 + } catch (e: Exception) { + setStatus(ReloadStatus.Failed(describeMintError(e))) + } + } + } + + private suspend fun rebalanceThenZap( + sourceMint: String, + targetMint: String, + moveSats: Long, + note: Note, + sendSats: Long, + ) { + val st = state ?: return + setStatus(ReloadStatus.Working("Moving funds", 0.1f)) + 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, targetMint) + } + + private suspend fun reloadFromLightningThenZap( + targetMint: String, + moveSats: Long, + walletUri: Nip47WalletConnect.Nip47URINorm?, + note: Note, + sendSats: 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, moveSats) + + 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, 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) { + paid = ops.checkMintQuote(targetMint, flow.mintQuote.quote).isSettled() + 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 + } + // 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) + awaitTargetFunded(targetMint, sendSats) + sendNutzapAndFinish(note, sendSats, targetMint) + } + + /** + * 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 + * 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, + sendSats: Long, + targetMint: String, + ) { + 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 = sendSats, + 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) + } + + private fun setStatus(status: ReloadStatus) { + _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. + 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. + */ + 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/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, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bfc2d40537..01d3b1c9ba 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -876,11 +876,27 @@ 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 + Top up mint + %1$s sats + Top up amount + Mint to top up + Funds from + Pay with Lightning + Mint new ecash from your Lightning wallet + Not enough here + %1$s sats available + Top up & send zap + Send zap + Waiting for payment… + Try again + 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 Zap Privacy Controls how your identity is shown when you send a zap. Connect Wallet 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..88128d7053 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ZapAmountMergeTest.kt @@ -0,0 +1,75 @@ +/* + * 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, 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( + zap: List, + onchain: List, + ) = AccountZapPreferencesInternal(zapAmountChoices = zap, onchainZapAmountChoices = onchain) + + @Test + fun unionsDefaultsPreservingOrderAndDeduped() { + assertEquals( + listOf(21L, 50L, 100L, 5_000L), + mergeZapAmounts(zaps(DefaultZapAmounts, DefaultOnchainZapAmounts)), + ) + } + + @Test + 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(21L, 100L, 10L, 7_777L, 1_000L), + 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))) + } +} 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)) + } +} 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(