refactor(desktop): use dialogs for wallet actions, center home content

Send, Receive, and Connect are now AlertDialogs instead of full-screen
sub-pages. The wallet home content is centered with widthIn(max=360.dp)
for a polished desktop look. Dialogs are the native desktop pattern
(consistent with zap dialog, bookmark dialog, tor settings dialog).

- ConnectWalletDialog: NWC URI input with paste + validation
- SendDialog: BOLT11 invoice input with paste + progress
- ReceiveDialog: amount/description input, transitions to show
  generated invoice with copy button
- Home content: centered balance card + action buttons + connection info

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-05-21 09:31:39 +03:00
co-authored by Claude Opus 4.6
parent 420a10df50
commit a15ce33807
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.desktop.ui.wallet
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -30,8 +31,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -73,13 +76,6 @@ import java.awt.datatransfer.StringSelection
import java.text.NumberFormat
import java.util.Locale
enum class WalletScreen {
HOME,
CONNECT,
SEND,
RECEIVE,
}
@Composable
fun WalletColumnScreen(
account: AccountState.LoggedIn,
@@ -92,28 +88,16 @@ fun WalletColumnScreen(
) {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var currentScreen by remember { mutableStateOf(WalletScreen.HOME) }
// NWC connection state
var nwcUri by remember { mutableStateOf("") }
var isConnecting by remember { mutableStateOf(false) }
var connectionError by remember { mutableStateOf<String?>(null) }
// Dialog visibility
var showConnectDialog by remember { mutableStateOf(false) }
var showSendDialog by remember { mutableStateOf(false) }
var showReceiveDialog by remember { mutableStateOf(false) }
// Balance state
var balanceSats by remember { mutableStateOf<Long?>(null) }
var isLoadingBalance by remember { mutableStateOf(false) }
// Send state
var sendInvoice by remember { mutableStateOf("") }
var isSending by remember { mutableStateOf(false) }
var sendResult by remember { mutableStateOf<String?>(null) }
// Receive state
var receiveAmount by remember { mutableStateOf("") }
var receiveDescription by remember { mutableStateOf("") }
var generatedInvoice by remember { mutableStateOf<String?>(null) }
var isGenerating by remember { mutableStateOf(false) }
val paymentHandler =
remember(relayManager, localCache) {
NwcPaymentHandler(relayManager, localCache)
@@ -128,24 +112,33 @@ fun WalletColumnScreen(
balanceSats = result.balanceMsats / 1000
}
else -> { /* silently fail on auto-fetch */ }
else -> {}
}
isLoadingBalance = false
}
}
Column(modifier = Modifier.fillMaxSize()) {
when (currentScreen) {
WalletScreen.HOME -> {
WalletHomeContent(
nwcConnection = nwcConnection,
balanceSats = balanceSats,
isLoadingBalance = isLoadingBalance,
onConnect = { currentScreen = WalletScreen.CONNECT },
onSend = { currentScreen = WalletScreen.SEND },
onReceive = { currentScreen = WalletScreen.RECEIVE },
onRefreshBalance = {
if (nwcConnection != null) {
Box(modifier = Modifier.fillMaxSize()) {
// Centered home content
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier = Modifier.widthIn(max = 360.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (nwcConnection == null) {
NoWalletContent(onConnect = { showConnectDialog = true })
} else {
WalletBalanceCard(
balanceSats = balanceSats,
isLoading = isLoadingBalance,
onRefresh = {
isLoadingBalance = true
scope.launch {
when (val result = paymentHandler.getBalance(nwcConnection)) {
@@ -163,242 +156,146 @@ fun WalletColumnScreen(
}
isLoadingBalance = false
}
},
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { showSendDialog = true },
modifier = Modifier.weight(1f),
) {
Icon(symbol = MaterialSymbols.ArrowUpward, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Send")
}
},
onDisconnect = {
OutlinedButton(
onClick = { showReceiveDialog = true },
modifier = Modifier.weight(1f),
) {
Icon(symbol = MaterialSymbols.ArrowDownward, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Receive")
}
}
HorizontalDivider()
Text(
text = "Connected Wallet",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Relay: ${nwcConnection.relayUri}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Wallet: ${nwcConnection.pubKeyHex.take(8)}...${nwcConnection.pubKeyHex.takeLast(8)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = {
accountManager.clearNwcConnection()
scope.launch {
snackbarHostState.showSnackbar("Wallet disconnected")
}
},
)
}
WalletScreen.CONNECT -> {
ConnectWalletContent(
nwcUri = nwcUri,
isConnecting = isConnecting,
error = connectionError,
onUriChanged = {
nwcUri = it
connectionError = null
},
onPasteFromClipboard = {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
val text =
try {
clipboard.getData(DataFlavor.stringFlavor) as? String
} catch (_: Exception) {
null
}
if (text != null) {
nwcUri = text
}
},
onConnect = {
val result = accountManager.setNwcConnection(nwcUri)
if (result.isSuccess) {
nwcUri = ""
currentScreen = WalletScreen.HOME
scope.launch {
snackbarHostState.showSnackbar("Wallet connected!")
}
} else {
connectionError = "Invalid NWC URI. Expected: nostr+walletconnect://..."
}
},
onBack = { currentScreen = WalletScreen.HOME },
)
}
WalletScreen.SEND -> {
SendContent(
invoice = sendInvoice,
isSending = isSending,
result = sendResult,
onInvoiceChanged = {
sendInvoice = it
sendResult = null
},
onPaste = {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
val text =
try {
clipboard.getData(DataFlavor.stringFlavor) as? String
} catch (_: Exception) {
null
}
if (text != null) sendInvoice = text
},
onSend = {
if (nwcConnection != null && sendInvoice.isNotBlank()) {
isSending = true
sendResult = null
scope.launch {
val result =
paymentHandler.payInvoice(
bolt11 = sendInvoice,
nwcConnection = nwcConnection,
)
when (result) {
is NwcPaymentHandler.PaymentResult.Success -> {
sendResult = "Payment successful!"
sendInvoice = ""
}
is NwcPaymentHandler.PaymentResult.Error -> {
sendResult = "Error: ${result.message}"
}
is NwcPaymentHandler.PaymentResult.Timeout -> {
sendResult = "Payment timed out"
}
}
isSending = false
}
}
},
hasWallet = nwcConnection != null,
onBack = { currentScreen = WalletScreen.HOME },
)
}
WalletScreen.RECEIVE -> {
ReceiveContent(
amount = receiveAmount,
description = receiveDescription,
generatedInvoice = generatedInvoice,
isGenerating = isGenerating,
onAmountChanged = { receiveAmount = it },
onDescriptionChanged = { receiveDescription = it },
onGenerate = {
if (nwcConnection != null) {
val amountSats = receiveAmount.toLongOrNull() ?: 0L
if (amountSats > 0) {
isGenerating = true
scope.launch {
val amountMsats = amountSats * 1000
val desc = receiveDescription.ifBlank { null }
when (val result = paymentHandler.makeInvoice(nwcConnection, amountMsats, desc)) {
is NwcPaymentHandler.InvoiceResult.Success -> {
generatedInvoice = result.invoice
}
is NwcPaymentHandler.InvoiceResult.Error -> {
snackbarHostState.showSnackbar("Invoice error: ${result.message}")
}
is NwcPaymentHandler.InvoiceResult.Timeout -> {
snackbarHostState.showSnackbar("Invoice request timed out")
}
}
isGenerating = false
}
}
}
},
onCopyInvoice = { invoice ->
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
clipboard.setContents(StringSelection(invoice), null)
scope.launch {
snackbarHostState.showSnackbar("Invoice copied to clipboard")
}
},
hasWallet = nwcConnection != null,
onBack = { currentScreen = WalletScreen.HOME },
)
balanceSats = null
scope.launch { snackbarHostState.showSnackbar("Wallet disconnected") }
}) {
Text("Disconnect", color = MaterialTheme.colorScheme.error)
}
}
}
}
Spacer(modifier = Modifier.weight(1f))
SnackbarHost(hostState = snackbarHostState)
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
@Composable
private fun WalletHomeContent(
nwcConnection: Nip47URINorm?,
balanceSats: Long?,
isLoadingBalance: Boolean,
onConnect: () -> Unit,
onSend: () -> Unit,
onReceive: () -> Unit,
onRefreshBalance: () -> Unit,
onDisconnect: () -> Unit,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (nwcConnection == null) {
// No wallet connected
NoWalletContent(onConnect = onConnect)
} else {
// Connected wallet
WalletBalanceCard(
balanceSats = balanceSats,
isLoading = isLoadingBalance,
walletRelay = nwcConnection.relayUri.toString(),
onRefresh = onRefreshBalance,
)
// -- Dialogs --
// Quick actions
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = onSend,
modifier = Modifier.weight(1f),
) {
Icon(symbol = MaterialSymbols.ArrowUpward, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Send")
if (showConnectDialog) {
ConnectWalletDialog(
onDismiss = { showConnectDialog = false },
onConnect = { uri ->
val result = accountManager.setNwcConnection(uri)
if (result.isSuccess) {
showConnectDialog = false
scope.launch { snackbarHostState.showSnackbar("Wallet connected!") }
}
OutlinedButton(
onClick = onReceive,
modifier = Modifier.weight(1f),
) {
Icon(symbol = MaterialSymbols.ArrowDownward, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Receive")
result.isSuccess
},
)
}
if (showSendDialog && nwcConnection != null) {
SendDialog(
onDismiss = { showSendDialog = false },
onSend = { invoice ->
scope.launch {
val result = paymentHandler.payInvoice(bolt11 = invoice, nwcConnection = nwcConnection)
when (result) {
is NwcPaymentHandler.PaymentResult.Success -> {
showSendDialog = false
snackbarHostState.showSnackbar("Payment successful!")
}
is NwcPaymentHandler.PaymentResult.Error -> {
snackbarHostState.showSnackbar("Error: ${result.message}")
}
is NwcPaymentHandler.PaymentResult.Timeout -> {
snackbarHostState.showSnackbar("Payment timed out")
}
}
}
}
},
)
}
HorizontalDivider()
if (showReceiveDialog && nwcConnection != null) {
ReceiveDialog(
onDismiss = { showReceiveDialog = false },
onGenerate = { amountSats, description ->
scope.launch {
val result =
paymentHandler.makeInvoice(
nwcConnection = nwcConnection,
amountMsats = amountSats * 1000,
description = description.ifBlank { null },
)
when (result) {
is NwcPaymentHandler.InvoiceResult.Success -> {
result.invoice
}
// Connection info
Text(
text = "Connected Wallet",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Relay: ${nwcConnection.relayUri}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Wallet: ${nwcConnection.pubKeyHex.take(8)}...${nwcConnection.pubKeyHex.takeLast(8)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
is NwcPaymentHandler.InvoiceResult.Error -> {
snackbarHostState.showSnackbar("Error: ${result.message}")
null
}
TextButton(onClick = onDisconnect) {
Text("Disconnect", color = MaterialTheme.colorScheme.error)
}
}
is NwcPaymentHandler.InvoiceResult.Timeout -> {
snackbarHostState.showSnackbar("Invoice request timed out")
null
}
}
}
},
paymentHandler = paymentHandler,
nwcConnection = nwcConnection,
snackbarHostState = snackbarHostState,
)
}
}
@Composable
private fun NoWalletContent(onConnect: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
@@ -429,15 +326,11 @@ private fun NoWalletContent(onConnect: () -> Unit) {
private fun WalletBalanceCard(
balanceSats: Long?,
isLoading: Boolean,
walletRelay: String,
onRefresh: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(
modifier = Modifier.padding(20.dp),
@@ -480,262 +373,239 @@ private fun WalletBalanceCard(
}
}
@Composable
private fun ConnectWalletContent(
nwcUri: String,
isConnecting: Boolean,
error: String?,
onUriChanged: (String) -> Unit,
onPasteFromClipboard: () -> Unit,
onConnect: () -> Unit,
onBack: () -> Unit,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
TextButton(onClick = onBack) {
Icon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Back")
}
Text(
text = "Connect Wallet",
style = MaterialTheme.typography.titleLarge,
)
Text(
text = "Paste your Nostr Wallet Connect URI to connect a Lightning wallet.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = nwcUri,
onValueChange = onUriChanged,
label = { Text("NWC URI") },
placeholder = { Text("nostr+walletconnect://...") },
modifier = Modifier.fillMaxWidth(),
singleLine = false,
maxLines = 4,
isError = error != null,
supportingText = error?.let { { Text(it) } },
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onPasteFromClipboard) {
Text("Paste from Clipboard")
}
}
Button(
onClick = onConnect,
enabled = nwcUri.isNotBlank() && !isConnecting,
modifier = Modifier.fillMaxWidth(),
) {
if (isConnecting) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(8.dp))
}
Text("Connect")
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = "Supported wallets:",
style = MaterialTheme.typography.titleSmall,
)
Text(
text = "Alby Hub, Phoenix, Coinos, LNbits, Zeus, Mutiny, Strike",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Get an NWC connection URI from your wallet's settings.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// -- Dialogs --
@Composable
private fun SendContent(
invoice: String,
isSending: Boolean,
result: String?,
onInvoiceChanged: (String) -> Unit,
onPaste: () -> Unit,
onSend: () -> Unit,
hasWallet: Boolean,
onBack: () -> Unit,
private fun ConnectWalletDialog(
onDismiss: () -> Unit,
onConnect: (String) -> Boolean,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
TextButton(onClick = onBack) {
Icon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Back")
}
var nwcUri by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
Text(
text = "Send Payment",
style = MaterialTheme.typography.titleLarge,
)
if (!hasWallet) {
Text(
text = "Connect a wallet first to send payments.",
color = MaterialTheme.colorScheme.error,
)
return
}
OutlinedTextField(
value = invoice,
onValueChange = onInvoiceChanged,
label = { Text("BOLT11 Invoice") },
placeholder = { Text("lnbc...") },
modifier = Modifier.fillMaxWidth(),
singleLine = false,
maxLines = 6,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onPaste) {
Text("Paste")
}
}
Button(
onClick = onSend,
enabled = invoice.isNotBlank() && !isSending,
modifier = Modifier.fillMaxWidth(),
) {
if (isSending) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary)
Spacer(modifier = Modifier.width(8.dp))
Text("Sending...")
} else {
Text("Pay Invoice")
}
}
if (result != null) {
val isError = result.startsWith("Error") || result.contains("timed out")
Text(
text = result,
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
}
}
}
@Composable
private fun ReceiveContent(
amount: String,
description: String,
generatedInvoice: String?,
isGenerating: Boolean,
onAmountChanged: (String) -> Unit,
onDescriptionChanged: (String) -> Unit,
onGenerate: () -> Unit,
onCopyInvoice: (String) -> Unit,
hasWallet: Boolean,
onBack: () -> Unit,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
TextButton(onClick = onBack) {
Icon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Back")
}
Text(
text = "Receive Payment",
style = MaterialTheme.typography.titleLarge,
)
if (!hasWallet) {
Text(
text = "Connect a wallet first to receive payments.",
color = MaterialTheme.colorScheme.error,
)
return
}
OutlinedTextField(
value = amount,
onValueChange = { new -> if (new.all { it.isDigit() }) onAmountChanged(new) },
label = { Text("Amount (sats)") },
placeholder = { Text("1000") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = description,
onValueChange = onDescriptionChanged,
label = { Text("Description (optional)") },
placeholder = { Text("What's this for?") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Button(
onClick = onGenerate,
enabled = amount.isNotBlank() && !isGenerating,
modifier = Modifier.fillMaxWidth(),
) {
if (isGenerating) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary)
Spacer(modifier = Modifier.width(8.dp))
}
Text("Create Invoice")
}
if (generatedInvoice != null) {
HorizontalDivider()
Text(
text = "Invoice Created",
style = MaterialTheme.typography.titleSmall,
)
Card(modifier = Modifier.fillMaxWidth()) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Connect Wallet") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = generatedInvoice,
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
"Paste your Nostr Wallet Connect URI.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = nwcUri,
onValueChange = {
nwcUri = it
error = null
},
label = { Text("NWC URI") },
placeholder = { Text("nostr+walletconnect://...") },
modifier = Modifier.fillMaxWidth(),
singleLine = false,
maxLines = 4,
isError = error != null,
supportingText = error?.let { { Text(it) } },
)
OutlinedButton(onClick = {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
val text =
try {
clipboard.getData(DataFlavor.stringFlavor) as? String
} catch (_: Exception) {
null
}
if (text != null) nwcUri = text
}) {
Text("Paste from Clipboard")
}
Text(
"Supported: Alby Hub, Phoenix, Coinos, LNbits, Zeus",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
Button(
onClick = { onCopyInvoice(generatedInvoice) },
modifier = Modifier.fillMaxWidth(),
onClick = {
if (!onConnect(nwcUri)) {
error = "Invalid NWC URI. Expected: nostr+walletconnect://..."
}
},
enabled = nwcUri.isNotBlank(),
) {
Text("Copy Invoice")
Text("Connect")
}
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@Composable
private fun SendDialog(
onDismiss: () -> Unit,
onSend: (String) -> Unit,
) {
var invoice by remember { mutableStateOf("") }
var isSending by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { if (!isSending) onDismiss() },
title = { Text("Send Payment") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = invoice,
onValueChange = { invoice = it },
label = { Text("BOLT11 Invoice") },
placeholder = { Text("lnbc...") },
modifier = Modifier.fillMaxWidth(),
singleLine = false,
maxLines = 6,
)
OutlinedButton(onClick = {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
val text =
try {
clipboard.getData(DataFlavor.stringFlavor) as? String
} catch (_: Exception) {
null
}
if (text != null) invoice = text
}) {
Text("Paste from Clipboard")
}
}
},
confirmButton = {
Button(
onClick = {
isSending = true
onSend(invoice)
},
enabled = invoice.isNotBlank() && !isSending,
) {
if (isSending) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary)
Spacer(modifier = Modifier.width(8.dp))
Text("Sending...")
} else {
Text("Pay Invoice")
}
}
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !isSending) { Text("Cancel") }
},
)
}
@Composable
private fun ReceiveDialog(
onDismiss: () -> Unit,
onGenerate: (Long, String) -> Unit,
paymentHandler: NwcPaymentHandler,
nwcConnection: Nip47URINorm,
snackbarHostState: SnackbarHostState,
) {
var amount by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var generatedInvoice by remember { mutableStateOf<String?>(null) }
var isGenerating by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
AlertDialog(
onDismissRequest = { if (!isGenerating) onDismiss() },
title = { Text(if (generatedInvoice != null) "Invoice Created" else "Receive Payment") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (generatedInvoice != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Text(
text = generatedInvoice!!,
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
maxLines = 6,
)
}
} else {
OutlinedTextField(
value = amount,
onValueChange = { new -> if (new.all { it.isDigit() }) amount = new },
label = { Text("Amount (sats)") },
placeholder = { Text("1000") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description (optional)") },
placeholder = { Text("What's this for?") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
}
},
confirmButton = {
if (generatedInvoice != null) {
Button(onClick = {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
clipboard.setContents(StringSelection(generatedInvoice), null)
scope.launch { snackbarHostState.showSnackbar("Invoice copied!") }
}) {
Text("Copy Invoice")
}
} else {
Button(
onClick = {
val amountSats = amount.toLongOrNull() ?: 0L
if (amountSats > 0) {
isGenerating = true
scope.launch {
val result =
paymentHandler.makeInvoice(
nwcConnection = nwcConnection,
amountMsats = amountSats * 1000,
description = description.ifBlank { null },
)
when (result) {
is NwcPaymentHandler.InvoiceResult.Success -> {
generatedInvoice = result.invoice
}
is NwcPaymentHandler.InvoiceResult.Error -> {
snackbarHostState.showSnackbar("Error: ${result.message}")
}
is NwcPaymentHandler.InvoiceResult.Timeout -> {
snackbarHostState.showSnackbar("Invoice request timed out")
}
}
isGenerating = false
}
}
},
enabled = amount.isNotBlank() && !isGenerating,
) {
if (isGenerating) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary)
Spacer(modifier = Modifier.width(8.dp))
}
Text("Create Invoice")
}
}
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !isGenerating) {
Text(if (generatedInvoice != null) "Close" else "Cancel")
}
},
)
}
private fun formatSats(sats: Long): String = NumberFormat.getNumberInstance(Locale.getDefault()).format(sats)