mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +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.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
|
||||
@@ -201,6 +202,10 @@ fun ReusableZapButton(
|
||||
onchainZapAmount = amount
|
||||
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.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
|
||||
@@ -343,6 +344,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) }
|
||||
|
||||
|
||||
@@ -639,6 +639,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
|
||||
|
||||
@@ -85,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.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.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
|
||||
@@ -1257,6 +1259,10 @@ fun ZapReaction(
|
||||
nav.nav(Route.ManualZapSplitPayment(uid))
|
||||
}
|
||||
},
|
||||
onReloadNutzap = { amount ->
|
||||
wantsToZap = false
|
||||
navigateToReloadMint(accountViewModel, nav, baseNote, amount)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1885,6 +1891,7 @@ 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
|
||||
@@ -1903,6 +1910,7 @@ fun ZapAmountChoicePopup(
|
||||
onPayViaIntent = onPayViaIntent,
|
||||
onOnchainAmount = onOnchainAmount ?: {},
|
||||
onchainSupported = onOnchainAmount != null,
|
||||
onReloadNutzap = onReloadNutzap,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1920,6 +1928,7 @@ fun ZapAmountChoicePopup(
|
||||
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
|
||||
onOnchainAmount: (Long?) -> Unit = {},
|
||||
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
|
||||
@@ -1955,6 +1964,7 @@ fun ZapAmountChoicePopup(
|
||||
onError,
|
||||
onProgress,
|
||||
onPayViaIntent,
|
||||
onReloadNutzap,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1973,6 +1983,7 @@ fun ZapAmountChoicePopup(
|
||||
onError: (title: String, text: String, user: User?) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
|
||||
onReloadNutzap: (Long) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val yOffset = with(LocalDensity.current) { -popupYOffset.toPx().toInt() }
|
||||
@@ -2025,6 +2036,10 @@ fun ZapAmountChoicePopup(
|
||||
onOnchainAmount(amount)
|
||||
visibilityState.targetState = false
|
||||
},
|
||||
onReloadNutzap = { amount ->
|
||||
onReloadNutzap(amount)
|
||||
visibilityState.targetState = false
|
||||
},
|
||||
onChangeAmount = onChangeAmount,
|
||||
)
|
||||
}
|
||||
@@ -2040,6 +2055,7 @@ fun ZapAmountChoicePopupContent(
|
||||
onNutzap: (Long) -> Unit,
|
||||
onOnchainAmount: (Long?) -> Unit,
|
||||
onChangeAmount: () -> Unit,
|
||||
onReloadNutzap: (Long) -> Unit = {},
|
||||
) {
|
||||
Box(HalfPadding, contentAlignment = Center) {
|
||||
ElevatedCard(
|
||||
@@ -2060,6 +2076,7 @@ fun ZapAmountChoicePopupContent(
|
||||
onLightningZap = onLightningZap,
|
||||
onNutzap = onNutzap,
|
||||
onOnchainAmount = onOnchainAmount,
|
||||
onReloadNutzap = onReloadNutzap,
|
||||
onChangeAmount = onChangeAmount,
|
||||
)
|
||||
}
|
||||
@@ -2085,17 +2102,19 @@ fun ZapAmountChoicePopupContent(
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* - **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 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.
|
||||
* Tapping the amount fires the cheapest/fastest available rail (cashu funded →
|
||||
* Lightning → cashu reload → 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
|
||||
@@ -2105,13 +2124,16 @@ private fun UnifiedZapAmountChip(
|
||||
onLightningZap: (Long) -> Unit,
|
||||
onNutzap: (Long) -> Unit,
|
||||
onOnchainAmount: (Long?) -> Unit,
|
||||
onReloadNutzap: (Long) -> 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 onchainReady = railCapability.hasOnchain && amountInSats >= MIN_ONCHAIN_ZAP_SATS
|
||||
|
||||
if (!cashuReady && !lightningReady && !onchainReady) return
|
||||
if (!cashuReady && !cashuReloadable && !lightningReady && !onchainReady) return
|
||||
|
||||
val defaultAction: () -> Unit =
|
||||
when {
|
||||
@@ -2121,6 +2143,9 @@ private fun UnifiedZapAmountChip(
|
||||
lightningReady -> {
|
||||
{ onLightningZap(amountInSats) }
|
||||
}
|
||||
cashuReloadable -> {
|
||||
{ onReloadNutzap(amountInSats) }
|
||||
}
|
||||
else -> {
|
||||
{ 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) {
|
||||
RailLogo(onClick = { onLightningZap(amountInSats) }) {
|
||||
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.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.
|
||||
|
||||
+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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+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_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">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_type_section_explainer">Controls how your identity is shown when you send a zap.</string>
|
||||
<string name="wallet_connect_connect_app">Connect Wallet</string>
|
||||
|
||||
Reference in New Issue
Block a user