mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
feat(zap): unify zap picker into one chip per amount with per-rail logos
Replace the three parallel chip rows (cashu / lightning / on-chain) in the zap amount popup with a single pill per amount that shows a tappable logo for each rail that can actually pay it. Tapping the amount fires the cheapest/fastest available rail (cashu funded -> lightning -> on-chain); tapping a specific logo forces that rail. Rail gating: - Cashu: precise per-amount status (FUNDED / NEEDS_RELOAD / IMPOSSIBLE / UNAVAILABLE) computed from in-memory proofs — the one balance that is free to read synchronously. Only FUNDED is offered for now. - Lightning: optimistic — an external wallet can pay any invoice, so it's offered whenever the recipient can receive, with no sender-balance fetch. - On-chain: offered when a backend is configured and the amount clears the on-chain minimum; UTXO sufficiency stays a send-time check. Also fixes the original "No proofs available at <mint>" failure: nutzap mint selection now picks the shared mint where we hold the most balance instead of the first one the recipient lists (peekNutzapFunding), so a zap no longer fails when funds sit in a different shared mint. Cashu capability is now resolved against the note author (who sendNutzap actually pays) rather than split recipients. Amount presets from the (still separate) lightning and on-chain settings are merged into one sorted set for display. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
This commit is contained in:
+55
-9
@@ -760,22 +760,55 @@ class CashuWalletState(
|
||||
* - we share no mint with them, or
|
||||
* - their kind:10019 has no P2PK pubkey.
|
||||
*/
|
||||
fun peekNutzapTarget(recipientPubKey: HexKey): NutzapTarget? {
|
||||
fun peekNutzapTarget(recipientPubKey: HexKey): NutzapTarget? = peekNutzapFunding(recipientPubKey)?.target
|
||||
|
||||
/**
|
||||
* Resolve nutzap funding for [recipientPubKey]: the best shared mint to
|
||||
* spend from plus the balance figures the zap picker needs to classify
|
||||
* each amount as funded, reloadable, or out of reach.
|
||||
*
|
||||
* The chosen [NutzapFunding.target] is the shared mint where we hold the
|
||||
* **most** balance — not merely the first one the recipient lists. The
|
||||
* previous `firstOrNull` could pick an empty shared mint and make a zap
|
||||
* fail with "No proofs available at <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() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1133,3 +1166,16 @@ data class NutzapTarget(
|
||||
val mintUrl: String,
|
||||
val recipientP2pkPubkeyHex: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Nutzap funding snapshot for a recipient — the [target] mint to spend from
|
||||
* plus the balance figures the zap picker uses to classify a given amount:
|
||||
* - `amount <= bestSingleMintSats` → fundable instantly from one shared mint
|
||||
* - `amount <= totalWalletSats` → fundable only after a mint reload/rebalance
|
||||
* - otherwise → out of reach with the current balance
|
||||
*/
|
||||
data class NutzapFunding(
|
||||
val target: NutzapTarget,
|
||||
val bestSingleMintSats: Long,
|
||||
val totalWalletSats: Long,
|
||||
)
|
||||
|
||||
@@ -46,12 +46,41 @@ data class RailCapability(
|
||||
val hasCashu: Boolean,
|
||||
val hasLightning: Boolean,
|
||||
val hasOnchain: Boolean,
|
||||
/** Largest balance held in any single mint shared with the recipient. */
|
||||
val cashuBestSingleMintSats: Long = 0L,
|
||||
/** Total cashu balance across all our mints (reachable via reload/rebalance). */
|
||||
val cashuTotalWalletSats: Long = 0L,
|
||||
) {
|
||||
/**
|
||||
* Classify a cashu nutzap of [amountSats] for the unified amount chip.
|
||||
* Cashu is the one rail whose balance is free to read synchronously, so
|
||||
* this is a precise per-amount status rather than the optimistic
|
||||
* recipient-only gating used for Lightning and on-chain.
|
||||
*/
|
||||
fun cashuStatus(amountSats: Long): CashuRailStatus =
|
||||
when {
|
||||
!hasCashu -> CashuRailStatus.UNAVAILABLE
|
||||
amountSats <= cashuBestSingleMintSats -> CashuRailStatus.FUNDED
|
||||
amountSats <= cashuTotalWalletSats -> CashuRailStatus.NEEDS_RELOAD
|
||||
else -> CashuRailStatus.IMPOSSIBLE
|
||||
}
|
||||
|
||||
companion object {
|
||||
val NONE = RailCapability(hasCashu = false, hasLightning = false, hasOnchain = false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-amount cashu spendability for the unified zap chip.
|
||||
* - [FUNDED]: a single shared mint already covers the amount — instant, no fee.
|
||||
* - [NEEDS_RELOAD]: total wallet balance covers it, but no single shared mint
|
||||
* does — needs a mint reload/rebalance first.
|
||||
* - [IMPOSSIBLE]: not enough cashu anywhere — a reload can't help.
|
||||
* - [UNAVAILABLE]: the recipient can't receive cashu at all (no kind:10019 /
|
||||
* no shared mint).
|
||||
*/
|
||||
enum class CashuRailStatus { FUNDED, NEEDS_RELOAD, IMPOSSIBLE, UNAVAILABLE }
|
||||
|
||||
object RailCapabilityResolver {
|
||||
/**
|
||||
* Resolve [RailCapability] for [baseNote]. Reads are synchronous from
|
||||
@@ -59,10 +88,11 @@ object RailCapabilityResolver {
|
||||
* inside a composable.
|
||||
*
|
||||
* Rules:
|
||||
* - **Cashu**: any pubkey-based recipient (author + `ZapSplitSetup`
|
||||
* splits) has a kind:10019 with a P2PK pubkey AND shares at least one
|
||||
* mint with our wallet. Delegates to [CashuWalletState.peekNutzapTarget]
|
||||
* which already enforces all three conditions.
|
||||
* - **Cashu**: the note **author** has a kind:10019 with a P2PK pubkey
|
||||
* AND shares at least one mint with our wallet. Computed against the
|
||||
* author only (not `zap` splits) because [CashuWalletState.sendNutzap]
|
||||
* pays the author. Delegates to [CashuWalletState.peekNutzapFunding],
|
||||
* which also reports per-mint balances for amount-level gating.
|
||||
* - **Lightning**: any pubkey recipient has lud16/lud06 in their kind:0,
|
||||
* OR the note has at least one direct `ZapSplitSetupLnAddress` split.
|
||||
* - **On-chain**: at least one pubkey-based recipient exists. NIP-BC
|
||||
@@ -92,7 +122,12 @@ object RailCapabilityResolver {
|
||||
return RailCapability.NONE
|
||||
}
|
||||
|
||||
val hasCashu = pubKeyRecipients.any { cashuState.peekNutzapTarget(it) != null }
|
||||
// Cashu is computed against the author only: sendNutzap pays
|
||||
// baseNote.author (it does not fan a nutzap across `zap` splits), so
|
||||
// gating on a split recipient's wallet would offer a chip the send
|
||||
// path can't honor.
|
||||
val cashuFunding = author?.let { cashuState.peekNutzapFunding(it) }
|
||||
val hasCashu = cashuFunding != null
|
||||
|
||||
val hasLightning =
|
||||
lnAddressOnlySplits.isNotEmpty() ||
|
||||
@@ -108,6 +143,8 @@ object RailCapabilityResolver {
|
||||
hasCashu = hasCashu,
|
||||
hasLightning = hasLightning,
|
||||
hasOnchain = hasOnchain,
|
||||
cashuBestSingleMintSats = cashuFunding?.bestSingleMintSats ?: 0L,
|
||||
cashuTotalWalletSats = cashuFunding?.totalWalletSats ?: 0L,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,10 +207,6 @@ fun ReusableZapButton(
|
||||
onchainZapAmount = amount
|
||||
showOnchainDialog = true
|
||||
},
|
||||
nutzapEnabled =
|
||||
baseNote.author?.pubkeyHex?.let { recipient ->
|
||||
accountViewModel.account.cashuWalletState.peekNutzapTarget(recipient) != null
|
||||
} ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -52,6 +53,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -83,6 +85,7 @@ import androidx.compose.ui.Alignment.Companion.Center
|
||||
import androidx.compose.ui.Alignment.Companion.CenterStart
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -111,6 +114,8 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ReactionRowAction
|
||||
import com.vitorpamplona.amethyst.model.ReactionRowItem
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.zap.CashuRailStatus
|
||||
import com.vitorpamplona.amethyst.model.zap.RailCapability
|
||||
import com.vitorpamplona.amethyst.model.zap.RailCapabilityResolver
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
|
||||
@@ -1887,21 +1892,9 @@ fun ZapAmountChoicePopup(
|
||||
accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
// Hide chips for rails the recipient(s) can't actually receive on. For a
|
||||
// single-author note this gates against the author's kind:0 / kind:10019;
|
||||
// for a note with `zap` split tags it considers every split recipient so
|
||||
// splits where (e.g.) only one of three has a lud16 still surface the LN
|
||||
// chips. Recomputed only when the note id changes — the underlying flows
|
||||
// (CashuWalletState, LocalCache user metadata) are stable for the life of
|
||||
// the popup, which is usually open for under a second.
|
||||
val railCapability =
|
||||
remember(baseNote) {
|
||||
RailCapabilityResolver.peek(baseNote, accountViewModel.account.cashuWalletState)
|
||||
}
|
||||
|
||||
ZapAmountChoicePopup(
|
||||
baseNote = baseNote,
|
||||
zapAmountChoices = if (railCapability.hasLightning) zapAmountChoices else persistentListOf(),
|
||||
zapAmountChoices = zapAmountChoices,
|
||||
accountViewModel = accountViewModel,
|
||||
popupYOffset = popupYOffset,
|
||||
onZapStarts = onZapStarts,
|
||||
@@ -1910,14 +1903,9 @@ fun ZapAmountChoicePopup(
|
||||
onError = onError,
|
||||
onProgress = onProgress,
|
||||
onPayViaIntent = onPayViaIntent,
|
||||
onchainZapAmountChoices =
|
||||
if (onOnchainAmount != null && railCapability.hasOnchain) {
|
||||
onchainZapAmountChoices
|
||||
} else {
|
||||
persistentListOf()
|
||||
},
|
||||
onchainZapAmountChoices = onchainZapAmountChoices,
|
||||
onOnchainAmount = onOnchainAmount ?: {},
|
||||
nutzapEnabled = railCapability.hasCashu,
|
||||
onchainSupported = onOnchainAmount != null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1935,13 +1923,33 @@ fun ZapAmountChoicePopup(
|
||||
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
|
||||
onchainZapAmountChoices: ImmutableList<Long> = persistentListOf(),
|
||||
onOnchainAmount: (Long?) -> Unit = {},
|
||||
nutzapEnabled: Boolean = false,
|
||||
onchainSupported: Boolean = true,
|
||||
) {
|
||||
// One chip per amount; the chip itself shows which rails can pay it. Merge
|
||||
// the (historically separate) Lightning and on-chain amount presets into a
|
||||
// single sorted set of choices. Rail availability per amount is decided in
|
||||
// [UnifiedZapAmountChip] from [railCapability], so the lists no longer need
|
||||
// to be pre-emptied per rail.
|
||||
//
|
||||
// [railCapability] gates which rails the recipient can receive on (plus the
|
||||
// sender's cashu balance, which is free to read). It's recomputed only when
|
||||
// the note changes — the underlying cache/wallet flows are stable for the
|
||||
// ~1s the popup is open. A caller that can't drive the on-chain dialog
|
||||
// (onchainSupported == false) masks that rail off here.
|
||||
val railCapability =
|
||||
remember(baseNote, onchainSupported) {
|
||||
val rc = RailCapabilityResolver.peek(baseNote, accountViewModel.account.cashuWalletState)
|
||||
if (onchainSupported) rc else rc.copy(hasOnchain = false)
|
||||
}
|
||||
val amountChoices =
|
||||
remember(zapAmountChoices, onchainZapAmountChoices) {
|
||||
(zapAmountChoices + onchainZapAmountChoices).distinct().sorted().toImmutableList()
|
||||
}
|
||||
val visibilityState = rememberVisibilityState(onDismiss)
|
||||
ZapAmountChoicePopup(
|
||||
baseNote,
|
||||
zapAmountChoices,
|
||||
onchainZapAmountChoices,
|
||||
amountChoices,
|
||||
railCapability,
|
||||
accountViewModel,
|
||||
popupYOffset,
|
||||
visibilityState,
|
||||
@@ -1951,7 +1959,6 @@ fun ZapAmountChoicePopup(
|
||||
onError,
|
||||
onProgress,
|
||||
onPayViaIntent,
|
||||
nutzapEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1959,8 +1966,8 @@ fun ZapAmountChoicePopup(
|
||||
@Composable
|
||||
fun ZapAmountChoicePopup(
|
||||
baseNote: Note,
|
||||
zapAmountChoices: ImmutableList<Long>,
|
||||
onchainZapAmountChoices: ImmutableList<Long>,
|
||||
amountChoices: ImmutableList<Long>,
|
||||
railCapability: RailCapability,
|
||||
accountViewModel: AccountViewModel,
|
||||
popupYOffset: Dp,
|
||||
visibilityState: MutableTransitionState<Boolean>,
|
||||
@@ -1970,7 +1977,6 @@ fun ZapAmountChoicePopup(
|
||||
onError: (title: String, text: String, user: User?) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
|
||||
nutzapEnabled: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val yOffset = with(LocalDensity.current) { -popupYOffset.toPx().toInt() }
|
||||
@@ -1987,9 +1993,9 @@ fun ZapAmountChoicePopup(
|
||||
exit = popupAnimationExit,
|
||||
) {
|
||||
ZapAmountChoicePopupContent(
|
||||
zapAmountChoices = zapAmountChoices,
|
||||
onchainZapAmountChoices = onchainZapAmountChoices,
|
||||
onZap = { amountInSats ->
|
||||
amountChoices = amountChoices,
|
||||
railCapability = railCapability,
|
||||
onLightningZap = { amountInSats ->
|
||||
onZapStarts()
|
||||
accountViewModel.zap(
|
||||
baseNote,
|
||||
@@ -2004,12 +2010,6 @@ fun ZapAmountChoicePopup(
|
||||
)
|
||||
visibilityState.targetState = false
|
||||
},
|
||||
onChangeAmount = onChangeAmount,
|
||||
onOnchainAmount = { amount ->
|
||||
onOnchainAmount(amount)
|
||||
visibilityState.targetState = false
|
||||
},
|
||||
nutzapAmountChoices = if (nutzapEnabled) zapAmountChoices else persistentListOf(),
|
||||
onNutzap = { amountInSats ->
|
||||
onZapStarts()
|
||||
// Instant click feedback. Without this initial nudge
|
||||
@@ -2025,6 +2025,11 @@ fun ZapAmountChoicePopup(
|
||||
)
|
||||
visibilityState.targetState = false
|
||||
},
|
||||
onOnchainAmount = { amount ->
|
||||
onOnchainAmount(amount)
|
||||
visibilityState.targetState = false
|
||||
},
|
||||
onChangeAmount = onChangeAmount,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2033,13 +2038,12 @@ fun ZapAmountChoicePopup(
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ZapAmountChoicePopupContent(
|
||||
zapAmountChoices: ImmutableList<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 = {},
|
||||
) {
|
||||
Box(HalfPadding, contentAlignment = Center) {
|
||||
ElevatedCard(
|
||||
@@ -2053,29 +2057,14 @@ fun ZapAmountChoicePopupContent(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
itemVerticalAlignment = CenterVertically,
|
||||
) {
|
||||
// Order: Cashu first (instant, no fees), then Lightning,
|
||||
// then on-chain — matches the recipient-capability fallback
|
||||
// order used elsewhere (RailCapabilityResolver) so the
|
||||
// "happiest path" rail surfaces first for the eye.
|
||||
nutzapAmountChoices.forEach { amountInSats ->
|
||||
NutzapAmountChip(
|
||||
amountChoices.forEach { amountInSats ->
|
||||
UnifiedZapAmountChip(
|
||||
amountInSats = amountInSats,
|
||||
onClick = { onNutzap(amountInSats) },
|
||||
onLongClick = onChangeAmount,
|
||||
)
|
||||
}
|
||||
zapAmountChoices.forEach { amountInSats ->
|
||||
ZapAmountChip(
|
||||
amountInSats = amountInSats,
|
||||
onClick = { onZap(amountInSats) },
|
||||
onLongClick = onChangeAmount,
|
||||
)
|
||||
}
|
||||
onchainZapAmountChoices.forEach { amountInSats ->
|
||||
OnchainZapAmountChip(
|
||||
amountInSats = amountInSats,
|
||||
onClick = { onOnchainAmount(amountInSats) },
|
||||
onLongClick = onChangeAmount,
|
||||
railCapability = railCapability,
|
||||
onLightningZap = onLightningZap,
|
||||
onNutzap = onNutzap,
|
||||
onOnchainAmount = onOnchainAmount,
|
||||
onChangeAmount = onChangeAmount,
|
||||
)
|
||||
}
|
||||
ClickableBox(
|
||||
@@ -2098,115 +2087,128 @@ fun ZapAmountChoicePopupContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Floor for the on-chain rail. Mirrors `MIN_ONCHAIN_ZAP_SATS` in
|
||||
* `OnchainZapSendDialog` (stricter than the protocol dust threshold); kept in
|
||||
* sync by hand because that one is private to the dialog. Below this the
|
||||
* on-chain logo is not offered for an amount.
|
||||
*/
|
||||
private const val MIN_ONCHAIN_ZAP_SATS = 1_000L
|
||||
|
||||
/**
|
||||
* One pill per amount, showing a tappable logo for every rail that can pay it:
|
||||
* - **Cashu** only when a single shared mint is already funded for the amount
|
||||
* (the one rail whose balance is free to read). Underfunded/reload states
|
||||
* are intentionally not offered yet.
|
||||
* - **Lightning** optimistically whenever the recipient can receive — an
|
||||
* external wallet can pay any invoice, so we don't gate on a sender balance.
|
||||
* - **On-chain** when a backend is configured and the amount clears
|
||||
* [MIN_ONCHAIN_ZAP_SATS]; UTXO sufficiency stays a send-time check.
|
||||
*
|
||||
* Tapping the amount fires the cheapest/fastest available rail (cashu →
|
||||
* Lightning → on-chain); tapping a specific logo forces that rail. Long-press
|
||||
* opens the amount-preset editor. A pill with no payable rail is not rendered.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun NutzapAmountChip(
|
||||
private fun UnifiedZapAmountChip(
|
||||
amountInSats: Long,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
railCapability: RailCapability,
|
||||
onLightningZap: (Long) -> Unit,
|
||||
onNutzap: (Long) -> Unit,
|
||||
onOnchainAmount: (Long?) -> Unit,
|
||||
onChangeAmount: () -> Unit,
|
||||
) {
|
||||
val cashuReady = railCapability.cashuStatus(amountInSats) == CashuRailStatus.FUNDED
|
||||
val lightningReady = railCapability.hasLightning
|
||||
val onchainReady = railCapability.hasOnchain && amountInSats >= MIN_ONCHAIN_ZAP_SATS
|
||||
|
||||
if (!cashuReady && !lightningReady && !onchainReady) return
|
||||
|
||||
val defaultAction: () -> Unit =
|
||||
when {
|
||||
cashuReady -> {
|
||||
{ onNutzap(amountInSats) }
|
||||
}
|
||||
lightningReady -> {
|
||||
{ onLightningZap(amountInSats) }
|
||||
}
|
||||
else -> {
|
||||
{ onOnchainAmount(amountInSats) }
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = ButtonBorder,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
.combinedClickable(onClick = defaultAction, onLongClick = onChangeAmount)
|
||||
.padding(start = 12.dp, end = 6.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalAlignment = CenterVertically,
|
||||
) {
|
||||
// CustomHashTagIcons.Cashu ships a multi-tone Cashu logo; using
|
||||
// tint=Unspecified preserves it instead of flattening to onTertiary
|
||||
// (which would lose the cashu-orange brand cue that distinguishes
|
||||
// this chip from the orange Lightning bolt next to it).
|
||||
Material3Icon(
|
||||
imageVector = CustomHashTagIcons.Cashu,
|
||||
contentDescription = stringRes(R.string.nutzap),
|
||||
modifier = Size18Modifier,
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
Text(
|
||||
text = showAmount(amountInSats.toBigDecimal().setScale(1)),
|
||||
color = MaterialTheme.colorScheme.onTertiary,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
if (cashuReady) {
|
||||
RailLogo(onClick = { onNutzap(amountInSats) }) {
|
||||
// CustomHashTagIcons.Cashu is a multi-tone logo; tint
|
||||
// Unspecified preserves its cashu-orange brand cue.
|
||||
Material3Icon(
|
||||
imageVector = CustomHashTagIcons.Cashu,
|
||||
contentDescription = stringRes(R.string.nutzap),
|
||||
modifier = Size18Modifier,
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (lightningReady) {
|
||||
RailLogo(onClick = { onLightningZap(amountInSats) }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Bolt,
|
||||
contentDescription = null,
|
||||
modifier = Size18Modifier,
|
||||
tint = BitcoinOrange,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (onchainReady) {
|
||||
RailLogo(onClick = { onOnchainAmount(amountInSats) }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.CurrencyBitcoin,
|
||||
contentDescription = null,
|
||||
modifier = Size18Modifier,
|
||||
tint = BitcoinOrange,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ZapAmountChip(
|
||||
amountInSats: Long,
|
||||
private fun RailLogo(
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = ButtonBorder,
|
||||
color = BitcoinOrange,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 3.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(2.dp),
|
||||
contentAlignment = Center,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Bolt,
|
||||
contentDescription = null,
|
||||
modifier = Size18Modifier,
|
||||
tint = Color.White,
|
||||
)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
Text(
|
||||
text = showAmount(amountInSats.toBigDecimal().setScale(1)),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun OnchainZapAmountChip(
|
||||
amountInSats: Long,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = ButtonBorder,
|
||||
color = BitcoinOrange,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.CurrencyBitcoin,
|
||||
contentDescription = null,
|
||||
modifier = Size18Modifier,
|
||||
tint = Color.White,
|
||||
)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
Text(
|
||||
text = showAmount(amountInSats.toBigDecimal().setScale(1)),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2215,11 +2217,19 @@ private fun OnchainZapAmountChip(
|
||||
fun ZapAmountChoicePopupPreview() {
|
||||
ThemeComparisonColumn {
|
||||
ZapAmountChoicePopupContent(
|
||||
zapAmountChoices = persistentListOf(50L, 100L, 500L, 1_000L, 5_000L, 10_000L, 100_000L),
|
||||
onchainZapAmountChoices = persistentListOf(10_000L, 50_000L, 250_000L),
|
||||
onZap = {},
|
||||
onChangeAmount = {},
|
||||
amountChoices = persistentListOf(50L, 100L, 500L, 1_000L, 5_000L, 10_000L, 100_000L),
|
||||
railCapability =
|
||||
RailCapability(
|
||||
hasCashu = true,
|
||||
hasLightning = true,
|
||||
hasOnchain = true,
|
||||
cashuBestSingleMintSats = 1_000L,
|
||||
cashuTotalWalletSats = 10_000L,
|
||||
),
|
||||
onLightningZap = {},
|
||||
onNutzap = {},
|
||||
onOnchainAmount = {},
|
||||
onChangeAmount = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user