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