feat(onchain-zaps): redesign send sheet + render in thread NoteMaster

OnchainZapSendDialog moves from AlertDialog to ModalBottomSheet —
matches the design language already used by CreateNestSheet,
EditNestSheet and the participant-action sheets. The form gets
imePadding + navigationBarsPadding, a scrollable content area, and a
sticky full-width Send button at the bottom.

Layout changes:
  - Header with Bitcoin ₿ glyph + title + close.
  - Recipient picker: the suggestion dropdown now sits flush under
    its text field (was: separated by the outer Column's spacedBy gap).
  - Amount section: big number field with 'sats' suffix + FlowRow of
    SuggestionChips bound to AccountViewModel.zapAmountChoices (same
    quick picks the LN zap dialog uses, formatted with showAmount).
  - Fee priority: FlowRow of FilterChips with explicit vertical
    padding around the row and inside each chip; each chip shows
    'Slow / Normal / Fast', the sat/vB rate, and a rough ETA.
    Selected chip uses bitcoinColor.
  - State machine: idle → sending (orange spinner) → success
    (bitcoinColor checkmark) / failure (error tint), each with a
    'Done' / 'Close' bottom button.

Also wire RenderOnchainZap into ThreadFeedView.NoteMaster's event-type
dispatch right after RenderLnZap, so kind 8333 receipts get the full
rich render on the thread screen instead of the default text body.
This commit is contained in:
Claude
2026-05-16 20:16:21 +00:00
parent 5aedcd0a19
commit e4b8e7ccc8
2 changed files with 435 additions and 183 deletions
@@ -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) {
@@ -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<out Event>? = 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<Long>,
) {
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()