Merge pull request #3108 from vitorpamplona/claude/amazing-volta-UJrwo

Unify zap amount chips and add cashu reload flow
This commit is contained in:
Vitor Pamplona
2026-05-30 10:33:33 -04:00
committed by GitHub
28 changed files with 1973 additions and 749 deletions
@@ -652,14 +652,12 @@ class Account(
suspend fun updateZapAmounts(
amountSet: List<Long>,
onchainAmountSet: List<Long>,
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
@@ -291,15 +291,6 @@ class AccountSettings(
return false
}
fun changeOnchainZapAmounts(newAmounts: List<Long>): Boolean {
if (syncedSettings.zaps.onchainZapAmountChoices.value != newAmounts) {
syncedSettings.zaps.onchainZapAmountChoices.tryEmit(newAmounts.toImmutableList())
saveAccountSettings()
return true
}
return false
}
fun changeReactionTypes(newTypes: List<String>): Boolean {
if (syncedSettings.reactions.reactionChoices.value != newTypes) {
syncedSettings.reactions.reactionChoices.tryEmit(newTypes.toImmutableList())
@@ -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<ImmutableList<Long>>,
var onchainZapAmountChoices: MutableStateFlow<ImmutableList<Long>>,
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType>,
)
/**
* 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<Long> = (zaps.zapAmountChoices + zaps.onchainZapAmountChoices).distinct()
@Stable
class AccountLanguagePreferences(
var dontTranslateFrom: MutableStateFlow<Set<String>>,
@@ -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<Long> = 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<Long> = DefaultOnchainZapAmounts,
val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
)
@@ -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 <mint>" 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<String, Long> =
_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<String> {
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<out Event>,
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,
)
@@ -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,
)
}
}
@@ -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
}
@@ -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<Long?>(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)
},
)
}
@@ -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<Route.OnchainTransactions> { OnchainTransactionsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletDetail> { WalletDetailScreen(it.walletId, accountViewModel, nav) }
composableFromEnd<Route.WalletAdd> { AddWalletScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletAddNwc> { AddNwcWalletScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletAddNwc> { AddNwcWalletScreen(accountViewModel, nav, it.nip47) }
composableFromEnd<Route.WalletAddCashu> { AddCashuWalletScreen(accountViewModel, nav) }
composableFromEnd<Route.CashuWallet> { CashuWalletScreen(accountViewModel, nav) }
composableFromEnd<Route.CashuWalletSettings> { CashuWalletSettingsScreen(accountViewModel, nav) }
@@ -345,6 +346,8 @@ fun BuildNavigation(
composableFromBottomArgs<Route.ManualZapSplitPayment> { PayViaIntentScreen(it.paymentId, accountViewModel, nav) }
composableFromBottomArgs<Route.ReloadMint> { ReloadMintScreen(it.requestId, accountViewModel, nav) }
composableFromBottomArgs<Route.EditProfile> { NewUserMetadataScreen(nav, accountViewModel) }
composable<Route.Search> { SearchScreen(accountViewModel, nav) }
@@ -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 <reified T : Route> isBaseRoute(navController: NavHostController): Boolean = navController.currentBackStackEntry?.destination?.hasRoute<T>() == true
@@ -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<ZapPaymentHandler.Payable>) -> 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<ZapPaymentHandler.Payable>) -> Unit,
onchainZapAmountChoices: ImmutableList<Long> = 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<NutzapInfoEvent>(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<Long>,
onchainZapAmountChoices: ImmutableList<Long>,
amountChoices: ImmutableList<Long>,
railCapability: RailCapability,
accountViewModel: AccountViewModel,
popupYOffset: Dp,
visibilityState: MutableTransitionState<Boolean>,
@@ -1970,7 +2017,7 @@ fun ZapAmountChoicePopup(
onError: (title: String, text: String, user: User?) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> 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<Long>,
onZap: (Long) -> Unit,
amountChoices: ImmutableList<Long>,
railCapability: RailCapability,
onLightningZap: (Long) -> Unit,
onNutzap: (Long) -> Unit,
onOnchainAmount: (Long?) -> Unit,
onChangeAmount: () -> Unit,
onchainZapAmountChoices: ImmutableList<Long> = persistentListOf(),
onOnchainAmount: (Long?) -> Unit = {},
nutzapAmountChoices: ImmutableList<Long> = 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<ZapRail> {
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<Long>,
) {
Text(label, style = MaterialTheme.typography.labelSmall)
ZapAmountChoicePopupContent(
amountChoices = amounts,
railCapability = caps,
onLightningZap = {},
onNutzap = {},
onOnchainAmount = {},
onReloadNutzap = {},
onChangeAmount = {},
)
}
fun showCount(count: Int?): String {
@@ -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<Long?>(null) }
var dragOffset by remember { mutableStateOf(Offset.Zero) }
val itemBounds = remember { mutableStateMapOf<Long, Rect>() }
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,
@@ -41,8 +41,6 @@ class UpdateZapAmountViewModel : ViewModel() {
var nextAmount by mutableStateOf(TextFieldValue(""))
var amountSet by mutableStateOf(listOf<Long>())
var nextOnchainAmount by mutableStateOf(TextFieldValue(""))
var onchainAmountSet by mutableStateOf(listOf<Long>())
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 ?: "")
@@ -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<String, List<ZapPaymentHandler.Payable>>(5)
/** Hand-off for the Reload Mint screen — keyed by a uid put on the back stack. */
val tempReloadRequestCache = LruCache<String, ReloadMintRequest>(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<Long>,
onchainAmountSet: List<Long>,
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) }
@@ -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)
},
)
}
@@ -122,7 +122,6 @@ fun NIP47SetupScreen(
paymentTargetsViewModel.refresh()
nav.popBack()
},
nip47,
accountViewModel,
) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
@@ -79,7 +79,7 @@ fun UpdateZapAmountScreen(
UpdateZapAmountContent(postViewModel, onClose = {
postViewModel.cancel()
nav.popBack()
}, nip47, accountViewModel)
}, accountViewModel)
}
}
}
@@ -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<String?>(null) }
var qrScanning by remember { mutableStateOf(false) }
@@ -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,
@@ -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)
@@ -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
@@ -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,
)
}
}
}
}
@@ -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<MintBalance> = persistentListOf(),
val targetOptions: ImmutableList<String> = 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<ReloadSource> = 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<ReloadUiState> = _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<String, Long>) {
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<ReloadSource> =
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<Event>() ?: 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>): 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
}
}
}
@@ -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,
+20 -4
View File
@@ -876,11 +876,27 @@
<string name="wallet_connect_status_not_connected">Not connected</string>
<string name="wallet_connect_manual_config">Advanced: enter connection details manually</string>
<string name="quick_zap_amounts">Quick Zap Amounts</string>
<string name="quick_zap_amounts_explainer">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.</string>
<string name="quick_zap_amounts_onchain">Quick On-chain Zap Amounts</string>
<string name="quick_zap_amounts_onchain_explainer">On-chain zaps pay miner fees, so amounts are usually larger than Lightning. Tap an amount to remove it.</string>
<string name="new_amount_in_sats_onchain">New on-chain amount in sats</string>
<string name="quick_zap_amounts_explainer">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.</string>
<string name="send_onchain_instead">Send on-chain instead</string>
<string name="reload_mint_title">Top up mint</string>
<string name="reload_mint_sats_amount">%1$s sats</string>
<string name="reload_mint_topup_label">Top up amount</string>
<string name="reload_mint_section_to">Mint to top up</string>
<string name="reload_mint_section_from">Funds from</string>
<string name="reload_mint_pay_lightning">Pay with Lightning</string>
<string name="reload_mint_lightning_desc">Mint new ecash from your Lightning wallet</string>
<string name="reload_mint_not_enough">Not enough here</string>
<string name="reload_mint_available">%1$s sats available</string>
<string name="reload_mint_confirm">Top up &amp; send zap</string>
<string name="reload_mint_send_confirm">Send zap</string>
<string name="reload_mint_awaiting_payment">Waiting for payment…</string>
<string name="reload_mint_retry">Try again</string>
<string name="reload_mint_summary">Top up %1$s sats to %2$s and zap %3$s %4$s sats · fee ≈ %5$s sats</string>
<string name="reload_mint_summary_funded">Zap %1$s %2$s sats from %3$s</string>
<string name="reload_mint_recipient_fallback">the recipient</string>
<string name="reload_mint_needs_more">needs %1$s sat more</string>
<string name="reload_mint_funded">funded</string>
<string name="reload_mint_copy_invoice">Copy invoice</string>
<string name="zap_privacy_section">Zap Privacy</string>
<string name="zap_type_section_explainer">Controls how your identity is shown when you send a zap.</string>
<string name="wallet_connect_connect_app">Connect Wallet</string>
@@ -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<Long>,
onchain: List<Long>,
) = 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)))
}
}
@@ -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))
}
}
@@ -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(