diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt index a5757930c9..e392236e93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt @@ -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) + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index e06be196ac..f7b8ac0192 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { PayViaIntentScreen(it.paymentId, accountViewModel, nav) } + composableFromBottomArgs { ReloadMintScreen(it.requestId, accountViewModel, nav) } + composableFromBottomArgs { NewUserMetadataScreen(nav, accountViewModel) } composable { SearchScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b319573c8e..af2d44aa01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -639,6 +639,11 @@ sealed class Route { data class ManualZapSplitPayment( val paymentId: String, ) : Route() + + @Serializable + data class ReloadMint( + val requestId: String, + ) : Route() } inline fun isBaseRoute(navController: NavHostController): Boolean = navController.currentBackStackEntry?.destination?.hasRoute() == true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 681b3f836f..a5f0c19f0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -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) -> 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) -> 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) -> 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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 3f034ebac0..ecaf7fb398 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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>(5) + /** Hand-off for the Reload Mint screen — keyed by a uid put on the back stack. */ + val tempReloadRequestCache = LruCache(5) + @OptIn(ExperimentalCoroutinesApi::class) val notificationHasNewItems = // When split-notifications is on, the badge tracks only the Following feed. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt index 623c717a85..274b9b3cae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt @@ -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) + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt new file mode 100644 index 0000000000..28dbe4affe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt @@ -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)) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt new file mode 100644 index 0000000000..43d44b3d35 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt @@ -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 = persistentListOf(), + val targetOptions: ImmutableList = persistentListOf(), + val selectedTarget: String = "", + val shortfallSats: Long = 0L, + val estFeeSats: Long = 0L, + val sources: ImmutableList = 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 = _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 = 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) } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e8c5e56fb8..9f8b9292e1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -871,6 +871,20 @@ Quick Zap Amounts 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. Send on-chain instead + Reload Mint + Top up the recipient\'s mint so you can send this nutzap in ecash. + Mint to reload + Your balances + Funds from + Pay with Lightning + Mint new ecash from your Lightning wallet + Not enough here + Move funds & send zap + Waiting for payment… + Try again + Move %1$s sat · fee ≈ %2$s sat + needs %1$s sat more + Copy invoice Zap Privacy Controls how your identity is shown when you send a zap. Connect Wallet