diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 6ff45b1f5..d0e69096e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -168,6 +168,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingRoomEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingSpaceEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval @@ -291,6 +292,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.collections.immutable.toImmutableList @@ -679,6 +681,8 @@ private fun FullBleedNoteCompose( DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is LnZapEvent) { RenderLnZap(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is OnchainZapEvent) { + RenderOnchainZap(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is SearchRelayListEvent) { DisplaySearchRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is BlockedRelayListEvent) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt index f98832772..2dc59bc59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -21,23 +21,34 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.AlertDialog +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -52,6 +63,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult import com.vitorpamplona.amethyst.model.LocalCache @@ -60,24 +72,27 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates +import com.vitorpamplona.quartz.utils.BigDecimal import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import androidx.compose.material3.ExperimentalMaterial3Api as ExpM3 -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon +import java.text.NumberFormat private enum class FeeTier( val label: String, + val etaLabel: String, ) { - SLOW("Slow"), - NORMAL("Normal"), - FAST("Fast"), + SLOW("Slow", "~1 hr"), + NORMAL("Normal", "~30 min"), + FAST("Fast", "~10 min"), } private fun FeeEstimates.rateFor(tier: FeeTier): Double = @@ -88,16 +103,22 @@ private fun FeeEstimates.rateFor(tier: FeeTier): Double = } /** - * Dialog that drives a NIP-BC onchain zap: collect recipient / amount / fee - * tier / comment, then run [com.vitorpamplona.amethyst.model.Account.sendOnchainZap] - * and show progress + the result. + * Modal bottom sheet that drives a NIP-BC onchain zap. * - * When [recipientPubKey] is null the user searches for a recipient by display - * name, NIP-05, or pastes an npub directly; the zap targets that profile. When - * provided (e.g. from a note's zap menu) the recipient is fixed and - * [zappedEvent] attributes the zap to that event. + * Layout (top to bottom): + * - Title row with close button + * - Recipient picker — search field with inline dropdown, or selected-user chip + * - Amount section — quick-pick chips (reuses [AccountViewModel.zapAmountChoices]) + * and a big sats text field + * - Optional comment + * - Fee priority — three chips with rate + ETA, in a FlowRow that wraps + * - Sticky bottom send button + * + * When [recipientPubKey] is null the user picks a recipient. When provided + * (e.g. from a note's zap menu) the recipient is fixed and [zappedEvent] + * attributes the zap to that event. */ -@OptIn(ExpM3::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun OnchainZapSendDialog( accountViewModel: AccountViewModel, @@ -105,6 +126,7 @@ fun OnchainZapSendDialog( recipientPubKey: HexKey? = null, zappedEvent: EventHintBundle? = null, ) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() val userSuggestions = @@ -131,15 +153,15 @@ fun OnchainZapSendDialog( runCatching { withContext(Dispatchers.IO) { backend.feeEstimates() } }.getOrNull() } - // Recipient resolution priority: - // 1. preset recipientPubKey (note zap menu) - // 2. user picked from the suggestion dropdown - // 3. raw npub / hex pasted into the search field that parses cleanly + val presetAmounts = + remember(accountViewModel) { + accountViewModel.zapAmountChoices() + } + val resolvedRecipient: HexKey? = recipientPubKey ?: selectedUser?.pubkeyHex ?: searchInput.trim().takeIf { it.isNotEmpty() }?.let { decodePublicKeyAsHexOrNull(it) } - val amountSats = amountInput.trim().toLongOrNull() val canSend = !sending && @@ -149,22 +171,55 @@ fun OnchainZapSendDialog( amountSats > 0 && fees != null - AlertDialog( + ModalBottomSheet( onDismissRequest = { if (!sending) onDismiss() }, - title = { Text("Onchain zap") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - when (val r = result) { - is OnchainZapSendResult.Success -> SuccessBody(r) - is OnchainZapSendResult.Failure -> FailureBody(r) - null -> { - if (sending) { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(modifier = Modifier.size(20.dp)) - Text(" Sending onchain zap…") - } - } else { - SendForm( + sheetState = sheetState, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .imePadding() + .navigationBarsPadding(), + ) { + Header(onClose = { if (!sending) onDismiss() }) + + when (val r = result) { + is OnchainZapSendResult.Success -> { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + SuccessBody(r) + } + DoneButton(label = "Done", onClick = onDismiss) + } + + is OnchainZapSendResult.Failure -> { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + FailureBody(r) + } + DoneButton(label = "Close", onClick = onDismiss) + } + + null -> { + if (sending) { + SendingState() + } else { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + ) { + RecipientSection( accountViewModel = accountViewModel, recipientPubKey = recipientPubKey, userSuggestions = userSuggestions, @@ -188,143 +243,111 @@ fun OnchainZapSendDialog( userSuggestions.reset() } }, + ) + + SectionSpacer() + + AmountSection( amountInput = amountInput, onAmountChange = { amountInput = it }, - comment = comment, - onCommentChange = { comment = it }, + presetAmounts = presetAmounts, + ) + + SectionSpacer() + + OutlinedTextField( + value = comment, + onValueChange = { comment = it }, + label = { Text("Comment (optional)") }, + modifier = Modifier.fillMaxWidth(), + ) + + SectionSpacer() + + FeeSection( feeTier = feeTier, onFeeTierChange = { feeTier = it }, fees = fees, ) + + Spacer(Modifier.height(20.dp)) } + + SendButton( + enabled = canSend, + amountSats = amountSats, + onClick = { + val recipient = resolvedRecipient ?: return@SendButton + val amount = amountSats ?: return@SendButton + val feeRate = fees?.rateFor(feeTier) ?: return@SendButton + sending = true + scope.launch { + val r = + accountViewModel.account.sendOnchainZap( + recipientPubKey = recipient, + amountSats = amount, + feeRateSatPerVByte = feeRate, + comment = comment.trim(), + zappedEvent = zappedEvent, + ) + sending = false + result = r + } + }, + ) } } } - }, - confirmButton = { - if (result != null) { - TextButton(onClick = onDismiss) { Text("Close") } - } else { - TextButton( - enabled = canSend, - onClick = { - val recipient = resolvedRecipient ?: return@TextButton - val amount = amountSats ?: return@TextButton - val feeRate = fees?.rateFor(feeTier) ?: return@TextButton - sending = true - scope.launch { - val r = - accountViewModel.account.sendOnchainZap( - recipientPubKey = recipient, - amountSats = amount, - feeRateSatPerVByte = feeRate, - comment = comment.trim(), - zappedEvent = zappedEvent, - ) - sending = false - result = r - } - }, - ) { - Text("Send") - } - } - }, - dismissButton = { - if (result == null) { - TextButton(onClick = onDismiss, enabled = !sending) { Text("Cancel") } - } - }, - ) + } + } } -@OptIn(ExpM3::class) @Composable -private fun SendForm( - accountViewModel: AccountViewModel, - recipientPubKey: HexKey?, - userSuggestions: UserSuggestionState, - selectedUser: User?, - onSelectUser: (User) -> Unit, - onClearUser: () -> Unit, - searchInput: String, - onSearchChange: (String) -> Unit, - amountInput: String, - onAmountChange: (String) -> Unit, - comment: String, - onCommentChange: (String) -> Unit, - feeTier: FeeTier, - onFeeTierChange: (FeeTier) -> Unit, - fees: FeeEstimates?, -) { - RecipientPicker( - accountViewModel = accountViewModel, - recipientPubKey = recipientPubKey, - userSuggestions = userSuggestions, - selectedUser = selectedUser, - onSelectUser = onSelectUser, - onClearUser = onClearUser, - searchInput = searchInput, - onSearchChange = onSearchChange, - ) +private fun SectionSpacer() { + Spacer(Modifier.height(16.dp)) +} - OutlinedTextField( - value = amountInput, - onValueChange = { onAmountChange(it.filter(Char::isDigit)) }, - label = { Text("Amount (sats)") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = comment, - onValueChange = onCommentChange, - label = { Text("Comment (optional)") }, - modifier = Modifier.fillMaxWidth(), - ) - - Text( - text = "Fee priority", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth(), +@Composable +private fun Header(onClose: () -> Unit) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - FeeTier.entries.forEach { tier -> - val rate = fees?.rateFor(tier) - FilterChip( - selected = feeTier == tier, - onClick = { onFeeTierChange(tier) }, - label = { - Column { - Text(tier.label) - if (rate != null) { - Text( - text = "${formatRate(rate)} sat/vB", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - }, + Box( + modifier = Modifier.size(28.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.CurrencyBitcoin, + contentDescription = null, + tint = MaterialTheme.colorScheme.bitcoinColor, + modifier = Modifier.size(22.dp), + ) + } + Text( + text = "Send onchain zap", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = + Modifier + .weight(1f) + .padding(start = 8.dp), + ) + IconButton(onClick = onClose) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - if (fees == null) { - Text( - text = "Loading fee estimates…", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } } @Composable -private fun RecipientPicker( +private fun RecipientSection( accountViewModel: AccountViewModel, recipientPubKey: HexKey?, userSuggestions: UserSuggestionState, @@ -334,12 +357,32 @@ private fun RecipientPicker( searchInput: String, onSearchChange: (String) -> Unit, ) { + SectionLabel("To") + if (recipientPubKey != null) { - Text( - text = "Zapping the post author", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture( + userHex = recipientPubKey, + size = 32.dp, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + Text( + text = "Post author", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 12.dp), + ) + } + } return } @@ -348,6 +391,8 @@ private fun RecipientPicker( return } + // Tight column: no extra parent spacing between the field and its + // suggestion dropdown — the dropdown sits flush under the field. OutlinedTextField( value = searchInput, onValueChange = onSearchChange, @@ -366,7 +411,7 @@ private fun RecipientPicker( userSuggestions = userSuggestions, onSelect = onSelectUser, accountViewModel = accountViewModel, - modifier = Modifier.heightIn(0.dp, 200.dp), + modifier = Modifier.heightIn(0.dp, 220.dp), ) } } @@ -388,11 +433,11 @@ private fun SelectedRecipientChip( ) { UserPicture( userHex = user.pubkeyHex, - size = 32.dp, + size = 36.dp, accountViewModel = accountViewModel, nav = EmptyNav(), ) - Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) { + Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { Text( text = user.toBestDisplayName(), style = MaterialTheme.typography.bodyMedium, @@ -409,7 +454,7 @@ private fun SelectedRecipientChip( ) } IconButton(onClick = onClear) { - SymbolIcon( + Icon( symbol = MaterialSymbols.Close, contentDescription = "Change recipient", tint = MaterialTheme.colorScheme.onSurfaceVariant, @@ -419,42 +464,245 @@ private fun SelectedRecipientChip( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SuccessBody(result: OnchainZapSendResult.Success) { - Text("Onchain zap sent.", style = MaterialTheme.typography.bodyLarge) - Text( - text = "Transaction: ${result.txid}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = - "Fee: ${result.feeSats} sats" + - if (result.changeSats > 0) " · change: ${result.changeSats} sats" else "", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, +private fun AmountSection( + amountInput: String, + onAmountChange: (String) -> Unit, + presetAmounts: List, +) { + SectionLabel("Amount") + + OutlinedTextField( + value = amountInput, + onValueChange = { onAmountChange(it.filter(Char::isDigit)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + placeholder = { Text("0") }, + suffix = { Text("sats", color = MaterialTheme.colorScheme.onSurfaceVariant) }, + modifier = Modifier.fillMaxWidth(), ) + + if (presetAmounts.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + presetAmounts.forEach { amount -> + SuggestionChip( + onClick = { onAmountChange(amount.toString()) }, + label = { Text("⚡ ${showAmount(BigDecimal(amount))}") }, + ) + } + } + } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun FailureBody(result: OnchainZapSendResult.Failure) { - Text( - text = result.message, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error, - ) - result.broadcastTxid?.let { +private fun FeeSection( + feeTier: FeeTier, + onFeeTierChange: (FeeTier) -> Unit, + fees: FeeEstimates?, +) { + SectionLabel("Priority") + + FlowRow( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FeeTier.entries.forEach { tier -> + val rate = fees?.rateFor(tier) + FilterChip( + selected = feeTier == tier, + onClick = { onFeeTierChange(tier) }, + colors = + FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.bitcoinColor, + selectedLabelColor = MaterialTheme.colorScheme.onPrimary, + ), + label = { + Column( + modifier = Modifier.padding(vertical = 4.dp), + ) { + Text( + text = tier.label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = if (rate != null) "${formatRate(rate)} sat/vB · ${tier.etaLabel}" else tier.etaLabel, + style = MaterialTheme.typography.labelSmall, + ) + } + }, + ) + } + } + + if (fees == null) { + Spacer(Modifier.height(4.dp)) Text( - text = "The payment was broadcast (tx $it) but the receipt was not published.", + text = "Loading fee estimates…", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } +} + +@Composable +private fun SectionLabel(text: String) { Text( - text = "Failed at: ${result.stage.name.lowercase().replace('_', ' ')}", - style = MaterialTheme.typography.bodySmall, + text = text, + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 6.dp), ) } +@Composable +private fun SendButton( + enabled: Boolean, + amountSats: Long?, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Icon( + symbol = MaterialSymbols.CurrencyBitcoin, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.size(8.dp)) + Text( + text = + if (amountSats != null && amountSats > 0) { + "Send ${NumberFormat.getNumberInstance().format(amountSats)} sats" + } else { + "Send" + }, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +private fun DoneButton( + label: String, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Text(label, fontWeight = FontWeight.SemiBold) + } +} + +@Composable +private fun SendingState() { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(36.dp), + color = MaterialTheme.colorScheme.bitcoinColor, + ) + Text( + text = "Building, signing and broadcasting…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun SuccessBody(result: OnchainZapSendResult.Success) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.bitcoinColor, + modifier = Modifier.size(28.dp), + ) + Spacer(Modifier.size(8.dp)) + Text( + text = "Onchain zap sent", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + ResultRow("Transaction", result.txid) + ResultRow("Fee", "${NumberFormat.getNumberInstance().format(result.feeSats)} sats") + if (result.changeSats > 0) { + ResultRow("Change", "${NumberFormat.getNumberInstance().format(result.changeSats)} sats") + } + } +} + +@Composable +private fun FailureBody(result: OnchainZapSendResult.Failure) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = result.message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + result.broadcastTxid?.let { + Text( + text = "Payment was broadcast (tx $it) but the receipt was not published.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = "Failed at: ${result.stage.name.lowercase().replace('_', ' ')}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ResultRow( + label: String, + value: String, +) { + Column { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + private fun formatRate(rate: Double): String = if (rate == rate.toLong().toDouble()) rate.toLong().toString() else ((rate * 10).toLong() / 10.0).toString()