mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(zap): Reload Mint screen to fund a recipient's mint, then nutzap
When the cashu logo on an amount chip is NEEDS_RELOAD (you hold enough cashu,
but not in the recipient's mint), it now shows a dimmed logo with a "+" badge
that opens a full Reload Mint screen instead of silently falling back to
Lightning.
The screen tells the whole story and auto-sends the nutzap on success:
- Destination ("mint to reload") with its shortfall; selectable when the
recipient accepts several mints we hold.
- All cashu balances for context.
- Source ("funds from"): another mint (mint-to-mint rebalance, no new sats) or
Lightning. Mint rows are disabled when they can't cover shortfall + fee.
Funding paths:
- Rebalance: CashuWalletState.rebalance moves cashu between mints.
- Lightning: mint a fresh quote at the destination, auto-pay via NWC when a
wallet is configured (else show the invoice to copy), poll, then issue.
Wiring: new Route.ReloadMint + AppNavigation entry; a ReloadMintRequest handed
off via an LruCache on AccountViewModel (mirrors the manual-zap pattern);
onReloadNutzap threaded through the zap popup overloads and the chip; callers
(ReusableZapButton, ReactionsRow, NestActionBar) navigate to the screen.
IMPOSSIBLE (not enough cashu anywhere) keeps falling back to Lightning — the
reload screen is only for moving existing cashu.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
This commit is contained in:
@@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.note.ZappedIcon
|
|||||||
import com.vitorpamplona.amethyst.ui.note.payViaIntent
|
import com.vitorpamplona.amethyst.ui.note.payViaIntent
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
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.OnchainZapSendDialog
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.navigateToReloadMint
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
|
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
|
||||||
@@ -201,6 +202,10 @@ fun ReusableZapButton(
|
|||||||
onchainZapAmount = amount
|
onchainZapAmount = amount
|
||||||
showOnchainDialog = true
|
showOnchainDialog = true
|
||||||
},
|
},
|
||||||
|
onReloadNutzap = { amount ->
|
||||||
|
wantsToZap = null
|
||||||
|
navigateToReloadMint(accountViewModel, nav, baseNote, amount)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddWalletScreen
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletSettingsScreen
|
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.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.WalletDetailScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen
|
||||||
@@ -343,6 +344,8 @@ fun BuildNavigation(
|
|||||||
|
|
||||||
composableFromBottomArgs<Route.ManualZapSplitPayment> { PayViaIntentScreen(it.paymentId, accountViewModel, nav) }
|
composableFromBottomArgs<Route.ManualZapSplitPayment> { PayViaIntentScreen(it.paymentId, accountViewModel, nav) }
|
||||||
|
|
||||||
|
composableFromBottomArgs<Route.ReloadMint> { ReloadMintScreen(it.requestId, accountViewModel, nav) }
|
||||||
|
|
||||||
composableFromBottomArgs<Route.EditProfile> { NewUserMetadataScreen(nav, accountViewModel) }
|
composableFromBottomArgs<Route.EditProfile> { NewUserMetadataScreen(nav, accountViewModel) }
|
||||||
composable<Route.Search> { SearchScreen(accountViewModel, nav) }
|
composable<Route.Search> { SearchScreen(accountViewModel, nav) }
|
||||||
|
|
||||||
|
|||||||
@@ -639,6 +639,11 @@ sealed class Route {
|
|||||||
data class ManualZapSplitPayment(
|
data class ManualZapSplitPayment(
|
||||||
val paymentId: String,
|
val paymentId: String,
|
||||||
) : Route()
|
) : Route()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReloadMint(
|
||||||
|
val requestId: String,
|
||||||
|
) : Route()
|
||||||
}
|
}
|
||||||
|
|
||||||
inline fun <reified T : Route> isBaseRoute(navController: NavHostController): Boolean = navController.currentBackStackEntry?.destination?.hasRoute<T>() == true
|
inline fun <reified T : Route> isBaseRoute(navController: NavHostController): Boolean = navController.currentBackStackEntry?.destination?.hasRoute<T>() == true
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import androidx.compose.ui.Alignment.Companion.Center
|
|||||||
import androidx.compose.ui.Alignment.Companion.CenterStart
|
import androidx.compose.ui.Alignment.Companion.CenterStart
|
||||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
@@ -146,6 +147,7 @@ import com.vitorpamplona.amethyst.ui.note.types.EditState
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.PaymentTargetsDialog
|
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.OnchainZapSendDialog
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.navigateToReloadMint
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||||
@@ -1257,6 +1259,10 @@ fun ZapReaction(
|
|||||||
nav.nav(Route.ManualZapSplitPayment(uid))
|
nav.nav(Route.ManualZapSplitPayment(uid))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onReloadNutzap = { amount ->
|
||||||
|
wantsToZap = false
|
||||||
|
navigateToReloadMint(accountViewModel, nav, baseNote, amount)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1885,6 +1891,7 @@ fun ZapAmountChoicePopup(
|
|||||||
onProgress: (percent: Float) -> Unit,
|
onProgress: (percent: Float) -> Unit,
|
||||||
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
|
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
|
||||||
onOnchainAmount: ((Long?) -> Unit)? = null,
|
onOnchainAmount: ((Long?) -> Unit)? = null,
|
||||||
|
onReloadNutzap: (Long) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val zapAmountChoices by
|
val zapAmountChoices by
|
||||||
accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices
|
accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices
|
||||||
@@ -1903,6 +1910,7 @@ fun ZapAmountChoicePopup(
|
|||||||
onPayViaIntent = onPayViaIntent,
|
onPayViaIntent = onPayViaIntent,
|
||||||
onOnchainAmount = onOnchainAmount ?: {},
|
onOnchainAmount = onOnchainAmount ?: {},
|
||||||
onchainSupported = onOnchainAmount != null,
|
onchainSupported = onOnchainAmount != null,
|
||||||
|
onReloadNutzap = onReloadNutzap,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1920,6 +1928,7 @@ fun ZapAmountChoicePopup(
|
|||||||
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
|
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
|
||||||
onOnchainAmount: (Long?) -> Unit = {},
|
onOnchainAmount: (Long?) -> Unit = {},
|
||||||
onchainSupported: Boolean = true,
|
onchainSupported: Boolean = true,
|
||||||
|
onReloadNutzap: (Long) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
// One chip per amount; the chip itself shows which rails can pay it.
|
// One chip per amount; the chip itself shows which rails can pay it.
|
||||||
// [zapAmountChoices] is already the single merged+sorted preset list (the
|
// [zapAmountChoices] is already the single merged+sorted preset list (the
|
||||||
@@ -1955,6 +1964,7 @@ fun ZapAmountChoicePopup(
|
|||||||
onError,
|
onError,
|
||||||
onProgress,
|
onProgress,
|
||||||
onPayViaIntent,
|
onPayViaIntent,
|
||||||
|
onReloadNutzap,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1973,6 +1983,7 @@ fun ZapAmountChoicePopup(
|
|||||||
onError: (title: String, text: String, user: User?) -> Unit,
|
onError: (title: String, text: String, user: User?) -> Unit,
|
||||||
onProgress: (percent: Float) -> Unit,
|
onProgress: (percent: Float) -> Unit,
|
||||||
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
|
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
|
||||||
|
onReloadNutzap: (Long) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val yOffset = with(LocalDensity.current) { -popupYOffset.toPx().toInt() }
|
val yOffset = with(LocalDensity.current) { -popupYOffset.toPx().toInt() }
|
||||||
@@ -2025,6 +2036,10 @@ fun ZapAmountChoicePopup(
|
|||||||
onOnchainAmount(amount)
|
onOnchainAmount(amount)
|
||||||
visibilityState.targetState = false
|
visibilityState.targetState = false
|
||||||
},
|
},
|
||||||
|
onReloadNutzap = { amount ->
|
||||||
|
onReloadNutzap(amount)
|
||||||
|
visibilityState.targetState = false
|
||||||
|
},
|
||||||
onChangeAmount = onChangeAmount,
|
onChangeAmount = onChangeAmount,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2040,6 +2055,7 @@ fun ZapAmountChoicePopupContent(
|
|||||||
onNutzap: (Long) -> Unit,
|
onNutzap: (Long) -> Unit,
|
||||||
onOnchainAmount: (Long?) -> Unit,
|
onOnchainAmount: (Long?) -> Unit,
|
||||||
onChangeAmount: () -> Unit,
|
onChangeAmount: () -> Unit,
|
||||||
|
onReloadNutzap: (Long) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Box(HalfPadding, contentAlignment = Center) {
|
Box(HalfPadding, contentAlignment = Center) {
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
@@ -2060,6 +2076,7 @@ fun ZapAmountChoicePopupContent(
|
|||||||
onLightningZap = onLightningZap,
|
onLightningZap = onLightningZap,
|
||||||
onNutzap = onNutzap,
|
onNutzap = onNutzap,
|
||||||
onOnchainAmount = onOnchainAmount,
|
onOnchainAmount = onOnchainAmount,
|
||||||
|
onReloadNutzap = onReloadNutzap,
|
||||||
onChangeAmount = onChangeAmount,
|
onChangeAmount = onChangeAmount,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2085,17 +2102,19 @@ fun ZapAmountChoicePopupContent(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One pill per amount, showing a tappable logo for every rail that can pay it:
|
* 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
|
* - **Cashu** as a solid logo when a single shared mint already covers the
|
||||||
* (the one rail whose balance is free to read). Underfunded/reload states
|
* amount (FUNDED), or as a dimmed logo with a "+" badge when funds exist but
|
||||||
* are intentionally not offered yet.
|
* 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
|
* - **Lightning** optimistically whenever the recipient can receive — an
|
||||||
* external wallet can pay any invoice, so we don't gate on a sender balance.
|
* 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
|
* - **On-chain** when a backend is configured and the amount clears
|
||||||
* [MIN_ONCHAIN_ZAP_SATS]; UTXO sufficiency stays a send-time check.
|
* [MIN_ONCHAIN_ZAP_SATS]; UTXO sufficiency stays a send-time check.
|
||||||
*
|
*
|
||||||
* Tapping the amount fires the cheapest/fastest available rail (cashu →
|
* Tapping the amount fires the cheapest/fastest available rail (cashu funded →
|
||||||
* Lightning → on-chain); tapping a specific logo forces that rail. Long-press
|
* Lightning → cashu reload → on-chain); tapping a specific logo forces that
|
||||||
* opens the amount-preset editor. A pill with no payable rail is not rendered.
|
* rail. Long-press opens the amount-preset editor. A pill with no payable rail
|
||||||
|
* is not rendered.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -2105,13 +2124,16 @@ private fun UnifiedZapAmountChip(
|
|||||||
onLightningZap: (Long) -> Unit,
|
onLightningZap: (Long) -> Unit,
|
||||||
onNutzap: (Long) -> Unit,
|
onNutzap: (Long) -> Unit,
|
||||||
onOnchainAmount: (Long?) -> Unit,
|
onOnchainAmount: (Long?) -> Unit,
|
||||||
|
onReloadNutzap: (Long) -> Unit,
|
||||||
onChangeAmount: () -> Unit,
|
onChangeAmount: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val cashuReady = railCapability.cashuStatus(amountInSats) == CashuRailStatus.FUNDED
|
val cashuStatus = railCapability.cashuStatus(amountInSats)
|
||||||
|
val cashuReady = cashuStatus == CashuRailStatus.FUNDED
|
||||||
|
val cashuReloadable = cashuStatus == CashuRailStatus.NEEDS_RELOAD
|
||||||
val lightningReady = railCapability.hasLightning
|
val lightningReady = railCapability.hasLightning
|
||||||
val onchainReady = railCapability.hasOnchain && amountInSats >= MIN_ONCHAIN_ZAP_SATS
|
val onchainReady = railCapability.hasOnchain && amountInSats >= MIN_ONCHAIN_ZAP_SATS
|
||||||
|
|
||||||
if (!cashuReady && !lightningReady && !onchainReady) return
|
if (!cashuReady && !cashuReloadable && !lightningReady && !onchainReady) return
|
||||||
|
|
||||||
val defaultAction: () -> Unit =
|
val defaultAction: () -> Unit =
|
||||||
when {
|
when {
|
||||||
@@ -2121,6 +2143,9 @@ private fun UnifiedZapAmountChip(
|
|||||||
lightningReady -> {
|
lightningReady -> {
|
||||||
{ onLightningZap(amountInSats) }
|
{ onLightningZap(amountInSats) }
|
||||||
}
|
}
|
||||||
|
cashuReloadable -> {
|
||||||
|
{ onReloadNutzap(amountInSats) }
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
{ onOnchainAmount(amountInSats) }
|
{ onOnchainAmount(amountInSats) }
|
||||||
}
|
}
|
||||||
@@ -2157,6 +2182,26 @@ private fun UnifiedZapAmountChip(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (cashuReloadable) {
|
||||||
|
// Funds exist but in the wrong mint — a dimmed cashu logo with a
|
||||||
|
// "+" badge; tap opens the Reload Mint screen to top it up first.
|
||||||
|
RailLogo(onClick = { onReloadNutzap(amountInSats) }) {
|
||||||
|
Row(verticalAlignment = CenterVertically) {
|
||||||
|
Material3Icon(
|
||||||
|
imageVector = CustomHashTagIcons.Cashu,
|
||||||
|
contentDescription = stringRes(R.string.reload_mint_title),
|
||||||
|
modifier = Size18Modifier.alpha(0.5f),
|
||||||
|
tint = Color.Unspecified,
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
symbol = MaterialSymbols.AddCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Size18Modifier,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (lightningReady) {
|
if (lightningReady) {
|
||||||
RailLogo(onClick = { onLightningZap(amountInSats) }) {
|
RailLogo(onClick = { onLightningZap(amountInSats) }) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
+4
@@ -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.CombinedZap
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NOTIFICATION_LAST_READ_KEY
|
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.relays.eventsync.EventSync
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.ReloadMintRequest
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||||
@@ -320,6 +321,9 @@ class AccountViewModel(
|
|||||||
|
|
||||||
val tempManualPaymentCache = LruCache<String, List<ZapPaymentHandler.Payable>>(5)
|
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)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
val notificationHasNewItems =
|
val notificationHasNewItems =
|
||||||
// When split-notifications is on, the badge tracks only the Following feed.
|
// When split-notifications is on, the badge tracks only the Following feed.
|
||||||
|
|||||||
+5
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.note.payViaIntent
|
|||||||
import com.vitorpamplona.amethyst.ui.note.zapClick
|
import com.vitorpamplona.amethyst.ui.note.zapClick
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
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.nests.room.reactions.RoomReactionPopup
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.navigateToReloadMint
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -565,6 +566,10 @@ private fun NestZapButton(
|
|||||||
nav.nav(Route.ManualZapSplitPayment(uid))
|
nav.nav(Route.ManualZapSplitPayment(uid))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onReloadNutzap = { amount ->
|
||||||
|
wantsToZap = false
|
||||||
|
navigateToReloadMint(accountViewModel, nav, roomNote, amount)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+336
@@ -0,0 +1,336 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stash the pending nutzap and open the Reload Mint screen. Called from the zap
|
||||||
|
* picker when the user taps an underfunded (NEEDS_RELOAD) cashu logo.
|
||||||
|
*/
|
||||||
|
fun navigateToReloadMint(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
baseNote: Note,
|
||||||
|
amountSats: Long,
|
||||||
|
) {
|
||||||
|
val uid = UUID.randomUUID().toString()
|
||||||
|
accountViewModel.tempReloadRequestCache.put(uid, ReloadMintRequest(baseNote, amountSats))
|
||||||
|
nav.nav(Route.ReloadMint(uid))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shortMint(url: String): String = url.removePrefix("https://").removePrefix("http://").removeSuffix("/")
|
||||||
|
|
||||||
|
private fun sats(value: Long): String = showAmount(value.toBigDecimal().setScale(1))
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ReloadMintScreen(
|
||||||
|
requestId: String,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
val request = remember(requestId) { accountViewModel.tempReloadRequestCache.get(requestId) }
|
||||||
|
|
||||||
|
// The cache entry can be gone after process death — nothing to fund, pop.
|
||||||
|
if (request == null) {
|
||||||
|
LaunchedEffect(Unit) { nav.popBack() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val viewModel: ReloadMintViewModel = viewModel()
|
||||||
|
LaunchedEffect(requestId) { viewModel.init(accountViewModel, request) }
|
||||||
|
|
||||||
|
val ui by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
if (ui.status is ReloadStatus.Done) {
|
||||||
|
LaunchedEffect(Unit) { nav.popBack() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringRes(R.string.reload_mint_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { nav.popBack() }) {
|
||||||
|
Icon(
|
||||||
|
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
|
||||||
|
contentDescription = stringRes(R.string.back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.reload_mint_subtitle),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.placeholderText,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Destination ───────────────────────────────────────────────
|
||||||
|
SectionHeader(stringRes(R.string.reload_mint_section_to))
|
||||||
|
if (ui.targetOptions.size > 1) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
ui.targetOptions.forEach { mint ->
|
||||||
|
FilterChip(
|
||||||
|
selected = mint == ui.selectedTarget,
|
||||||
|
onClick = { viewModel.selectTarget(mint) },
|
||||||
|
label = { Text(shortMint(mint), maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(14.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = shortMint(ui.selectedTarget),
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.reload_mint_needs_more, sats(ui.shortfallSats)),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── All balances ──────────────────────────────────────────────
|
||||||
|
SectionHeader(stringRes(R.string.reload_mint_section_balances))
|
||||||
|
ui.balances.forEach { bal ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = shortMint(bal.mintUrl),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color =
|
||||||
|
if (bal.mintUrl == ui.selectedTarget) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(text = "${sats(bal.balanceSats)} sat", color = MaterialTheme.colorScheme.placeholderText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Source ────────────────────────────────────────────────────
|
||||||
|
SectionHeader(stringRes(R.string.reload_mint_section_from))
|
||||||
|
ui.sources.forEach { source ->
|
||||||
|
SourceRow(
|
||||||
|
source = source,
|
||||||
|
selected = source == ui.selectedSource,
|
||||||
|
onSelect = { viewModel.selectSource(source) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// ── Summary + action ──────────────────────────────────────────
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.reload_mint_move_summary, sats(ui.shortfallSats), sats(ui.estFeeSats)),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
|
||||||
|
when (val status = ui.status) {
|
||||||
|
is ReloadStatus.Working -> {
|
||||||
|
LinearProgressIndicator(progress = { status.progress }, modifier = Modifier.fillMaxWidth())
|
||||||
|
Text(status.step, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReloadStatus.AwaitingInvoice -> {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.height(20.dp))
|
||||||
|
Text(stringRes(R.string.reload_mint_awaiting_payment), style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { copyToClipboard(context, status.invoice) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringRes(R.string.reload_mint_copy_invoice))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReloadStatus.Failed -> {
|
||||||
|
Text(status.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Button(onClick = { viewModel.confirm() }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(stringRes(R.string.reload_mint_retry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val source = ui.selectedSource
|
||||||
|
val enabled = source is ReloadSource.Lightning || (source is ReloadSource.Mint && source.canCover)
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.confirm() },
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringRes(R.string.reload_mint_confirm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SourceRow(
|
||||||
|
source: ReloadSource,
|
||||||
|
selected: Boolean,
|
||||||
|
onSelect: () -> Unit,
|
||||||
|
) {
|
||||||
|
val enabled = source is ReloadSource.Lightning || (source is ReloadSource.Mint && source.canCover)
|
||||||
|
val title =
|
||||||
|
when (source) {
|
||||||
|
is ReloadSource.Mint -> shortMint(source.mintUrl)
|
||||||
|
ReloadSource.Lightning -> stringRes(R.string.reload_mint_pay_lightning)
|
||||||
|
}
|
||||||
|
val trailing =
|
||||||
|
when (source) {
|
||||||
|
is ReloadSource.Mint ->
|
||||||
|
if (source.canCover) "${sats(source.balanceSats)} sat" else stringRes(R.string.reload_mint_not_enough)
|
||||||
|
ReloadSource.Lightning -> stringRes(R.string.reload_mint_lightning_desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
},
|
||||||
|
),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectable(selected = selected, enabled = enabled, onClick = onSelect),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(14.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.placeholderText,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = trailing,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.placeholderText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyToClipboard(
|
||||||
|
context: Context,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("invoice", text))
|
||||||
|
}
|
||||||
+280
@@ -0,0 +1,280 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState
|
||||||
|
import com.vitorpamplona.amethyst.model.nip60Cashu.describeMintError
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
|
/** Hand-off payload for the Reload Mint screen — the pending nutzap to fund. */
|
||||||
|
data class ReloadMintRequest(
|
||||||
|
val baseNote: Note,
|
||||||
|
val amountSats: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Where the reload funds come from. */
|
||||||
|
@Immutable
|
||||||
|
sealed interface ReloadSource {
|
||||||
|
/** Move existing cashu from another mint we hold (no new sats). */
|
||||||
|
data class Mint(
|
||||||
|
val mintUrl: String,
|
||||||
|
val balanceSats: Long,
|
||||||
|
val canCover: Boolean,
|
||||||
|
) : ReloadSource
|
||||||
|
|
||||||
|
/** Pay a fresh Lightning invoice to mint at the destination. */
|
||||||
|
data object Lightning : ReloadSource
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A mint we hold a balance at, for the balances list. */
|
||||||
|
@Immutable
|
||||||
|
data class MintBalance(
|
||||||
|
val mintUrl: String,
|
||||||
|
val balanceSats: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Progress of the reload + send. */
|
||||||
|
@Immutable
|
||||||
|
sealed interface ReloadStatus {
|
||||||
|
data object Configuring : ReloadStatus
|
||||||
|
|
||||||
|
data class Working(
|
||||||
|
val step: String,
|
||||||
|
val progress: Float,
|
||||||
|
) : ReloadStatus
|
||||||
|
|
||||||
|
/** No NWC wallet — the user must pay [invoice] externally; we keep polling. */
|
||||||
|
data class AwaitingInvoice(
|
||||||
|
val invoice: String,
|
||||||
|
val sats: Long,
|
||||||
|
) : ReloadStatus
|
||||||
|
|
||||||
|
data object Done : ReloadStatus
|
||||||
|
|
||||||
|
data class Failed(
|
||||||
|
val message: String,
|
||||||
|
) : ReloadStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ReloadUiState(
|
||||||
|
val recipient: String = "",
|
||||||
|
val amountSats: Long = 0L,
|
||||||
|
val balances: ImmutableList<MintBalance> = persistentListOf(),
|
||||||
|
val targetOptions: ImmutableList<String> = persistentListOf(),
|
||||||
|
val selectedTarget: String = "",
|
||||||
|
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
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(ReloadUiState())
|
||||||
|
val uiState: StateFlow<ReloadUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun init(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
request: ReloadMintRequest,
|
||||||
|
) {
|
||||||
|
this.accountViewModel = accountViewModel
|
||||||
|
this.baseNote = request.baseNote
|
||||||
|
val st = state ?: return
|
||||||
|
val recipient = request.baseNote.author?.pubkeyHex ?: return
|
||||||
|
|
||||||
|
val balances = st.peekMintBalances()
|
||||||
|
val targets =
|
||||||
|
st
|
||||||
|
.recipientSharedMints(recipient)
|
||||||
|
.sortedByDescending { balances[it] ?: 0L }
|
||||||
|
val defaultTarget = targets.firstOrNull() ?: return
|
||||||
|
|
||||||
|
_uiState.value =
|
||||||
|
ReloadUiState(
|
||||||
|
recipient = recipient,
|
||||||
|
amountSats = request.amountSats,
|
||||||
|
balances =
|
||||||
|
balances.entries
|
||||||
|
.sortedByDescending { it.value }
|
||||||
|
.map { MintBalance(it.key, it.value) }
|
||||||
|
.toImmutableList(),
|
||||||
|
targetOptions = targets.toImmutableList(),
|
||||||
|
selectedTarget = defaultTarget,
|
||||||
|
).let { recomputeFor(it, defaultTarget) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recompute shortfall, fee estimate, sources, and a default source. */
|
||||||
|
private fun recomputeFor(
|
||||||
|
base: ReloadUiState,
|
||||||
|
target: String,
|
||||||
|
): ReloadUiState {
|
||||||
|
val balances = base.balances.associate { it.mintUrl to it.balanceSats }
|
||||||
|
val targetBalance = balances[target] ?: 0L
|
||||||
|
val shortfall = (base.amountSats - targetBalance).coerceAtLeast(0L)
|
||||||
|
// Rough fee cushion for enabling a source; the melt quote sets the real
|
||||||
|
// fee at execution time. 1% (min 1 sat).
|
||||||
|
val estFee = (shortfall / 100L).coerceAtLeast(1L)
|
||||||
|
|
||||||
|
val mintSources =
|
||||||
|
base.balances
|
||||||
|
.filter { it.mintUrl != target }
|
||||||
|
.map { ReloadSource.Mint(it.mintUrl, it.balanceSats, canCover = it.balanceSats >= shortfall + estFee) }
|
||||||
|
|
||||||
|
val sources: List<ReloadSource> = mintSources + ReloadSource.Lightning
|
||||||
|
// Prefer the richest mint that can cover (no new sats); else Lightning.
|
||||||
|
val defaultSource: ReloadSource =
|
||||||
|
mintSources.filter { it.canCover }.maxByOrNull { it.balanceSats } ?: ReloadSource.Lightning
|
||||||
|
|
||||||
|
return base.copy(
|
||||||
|
selectedTarget = target,
|
||||||
|
shortfallSats = shortfall,
|
||||||
|
estFeeSats = estFee,
|
||||||
|
sources = sources.toImmutableList(),
|
||||||
|
selectedSource = defaultSource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectTarget(mintUrl: String) {
|
||||||
|
_uiState.update { recomputeFor(it, mintUrl) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectSource(source: ReloadSource) {
|
||||||
|
_uiState.update { it.copy(selectedSource = source) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun confirm() {
|
||||||
|
val vm = accountViewModel ?: return
|
||||||
|
val note = baseNote ?: return
|
||||||
|
val s = _uiState.value
|
||||||
|
val source = s.selectedSource ?: return
|
||||||
|
|
||||||
|
vm.launchSigner {
|
||||||
|
try {
|
||||||
|
when (source) {
|
||||||
|
is ReloadSource.Mint -> rebalanceThenZap(source.mintUrl, s.selectedTarget, s.shortfallSats, note, s.amountSats)
|
||||||
|
ReloadSource.Lightning -> reloadFromLightningThenZap(s.selectedTarget, s.shortfallSats, note, s.amountSats)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
setStatus(ReloadStatus.Failed(describeMintError(e)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun rebalanceThenZap(
|
||||||
|
sourceMint: String,
|
||||||
|
targetMint: String,
|
||||||
|
shortfall: Long,
|
||||||
|
note: Note,
|
||||||
|
amount: Long,
|
||||||
|
) {
|
||||||
|
val st = state ?: return
|
||||||
|
setStatus(ReloadStatus.Working("Moving funds", 0.1f))
|
||||||
|
st.rebalance(sourceMint, targetMint, shortfall) { p ->
|
||||||
|
setStatus(ReloadStatus.Working("Moving funds", p.coerceIn(0.1f, 0.9f)))
|
||||||
|
}
|
||||||
|
sendNutzapAndFinish(note, amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun reloadFromLightningThenZap(
|
||||||
|
targetMint: String,
|
||||||
|
shortfall: Long,
|
||||||
|
note: Note,
|
||||||
|
amount: Long,
|
||||||
|
) {
|
||||||
|
val vm = accountViewModel ?: return
|
||||||
|
val st = state ?: return
|
||||||
|
val ops = st.ops
|
||||||
|
|
||||||
|
setStatus(ReloadStatus.Working("Requesting invoice", 0.1f))
|
||||||
|
val flow = ops.startMintFromLightning(targetMint, shortfall)
|
||||||
|
|
||||||
|
val walletUri = vm.account.settings.defaultZapPaymentRequest()
|
||||||
|
if (walletUri != null) {
|
||||||
|
setStatus(ReloadStatus.Working("Paying from your wallet", 0.35f))
|
||||||
|
// Fire-and-forget: the mint-quote poll below is the source of truth
|
||||||
|
// for whether the payment actually landed.
|
||||||
|
runCatching {
|
||||||
|
vm.account.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No NWC — surface the invoice for an external wallet and keep polling.
|
||||||
|
setStatus(ReloadStatus.AwaitingInvoice(flow.invoice, shortfall))
|
||||||
|
}
|
||||||
|
|
||||||
|
val attempts = 90
|
||||||
|
val delayMs = 2_000L
|
||||||
|
var paid = false
|
||||||
|
var attempt = 0
|
||||||
|
while (!paid && attempt < attempts) {
|
||||||
|
val status = ops.checkMintQuote(targetMint, flow.mintQuote.quote)
|
||||||
|
paid = status.paid == true || status.state == "PAID" || status.state == "ISSUED"
|
||||||
|
if (!paid) {
|
||||||
|
delay(delayMs)
|
||||||
|
attempt++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!paid) {
|
||||||
|
setStatus(ReloadStatus.Failed("Invoice not paid yet — you can finish it later from the pending quote banner"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus(ReloadStatus.Working("Issuing ecash", 0.85f))
|
||||||
|
ops.completeMintFromLightning(targetMint, flow.quoteEvent, shortfall)
|
||||||
|
sendNutzapAndFinish(note, amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendNutzapAndFinish(
|
||||||
|
note: Note,
|
||||||
|
amount: Long,
|
||||||
|
) {
|
||||||
|
val vm = accountViewModel ?: return
|
||||||
|
setStatus(ReloadStatus.Working("Sending zap", 0.95f))
|
||||||
|
// The destination is now funded; fire the nutzap (its own error path
|
||||||
|
// toasts) and report Done so the screen can pop.
|
||||||
|
vm.sendNutzap(
|
||||||
|
baseNote = note,
|
||||||
|
amountSats = amount,
|
||||||
|
message = "",
|
||||||
|
onError = { _, msg, _ -> setStatus(ReloadStatus.Failed(msg)) },
|
||||||
|
)
|
||||||
|
setStatus(ReloadStatus.Done)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setStatus(status: ReloadStatus) {
|
||||||
|
_uiState.update { it.copy(status = status) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -871,6 +871,20 @@
|
|||||||
<string name="quick_zap_amounts">Quick Zap Amounts</string>
|
<string name="quick_zap_amounts">Quick Zap Amounts</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="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="send_onchain_instead">Send on-chain instead</string>
|
||||||
|
<string name="reload_mint_title">Reload Mint</string>
|
||||||
|
<string name="reload_mint_subtitle">Top up the recipient\'s mint so you can send this nutzap in ecash.</string>
|
||||||
|
<string name="reload_mint_section_to">Mint to reload</string>
|
||||||
|
<string name="reload_mint_section_balances">Your balances</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_confirm">Move funds & 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_move_summary">Move %1$s sat · fee ≈ %2$s sat</string>
|
||||||
|
<string name="reload_mint_needs_more">needs %1$s sat more</string>
|
||||||
|
<string name="reload_mint_copy_invoice">Copy invoice</string>
|
||||||
<string name="zap_privacy_section">Zap Privacy</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="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>
|
<string name="wallet_connect_connect_app">Connect Wallet</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user