mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
Merge pull request #3108 from vitorpamplona/claude/amazing-volta-UJrwo
Unify zap amount chips and add cashu reload flow
This commit is contained in:
@@ -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>>,
|
||||
|
||||
+22
@@ -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,
|
||||
)
|
||||
|
||||
+202
-11
@@ -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
|
||||
}
|
||||
|
||||
+5
-10
@@ -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 {
|
||||
|
||||
+139
-485
@@ -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,
|
||||
|
||||
+15
-19
@@ -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 ?: "")
|
||||
|
||||
+5
-2
@@ -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) }
|
||||
|
||||
|
||||
+5
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -122,7 +122,6 @@ fun NIP47SetupScreen(
|
||||
paymentTargetsViewModel.refresh()
|
||||
nav.popBack()
|
||||
},
|
||||
nip47,
|
||||
accountViewModel,
|
||||
) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ fun UpdateZapAmountScreen(
|
||||
UpdateZapAmountContent(postViewModel, onClose = {
|
||||
postViewModel.cancel()
|
||||
nav.popBack()
|
||||
}, nip47, accountViewModel)
|
||||
}, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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) }
|
||||
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+12
-8
@@ -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
|
||||
|
||||
+442
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+488
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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,
|
||||
|
||||
@@ -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 & 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)))
|
||||
}
|
||||
}
|
||||
+79
@@ -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))
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user