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:
+4
@@ -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) {
|
||||||
|
|||||||
+431
-183
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user