feat(wallet): redesign onchain card to match NWC WalletCard

OnchainSection now mirrors WalletCard's layout structurally so both
payment rails read as the same kind of object on the wallet screen,
with bitcoin-orange (BitcoinDark #F7931A / BitcoinLight #B66605)
replacing NWC's primary purple to keep them distinct at a glance:

  - RoundedCornerShape(16.dp), 2.dp bitcoinColor border,
    bitcoinColor.copy(alpha = 0.12f) container — same idiom NWC uses
    for the 'default wallet' card.
  - Header row: small orange ₿ chip + 'Bitcoin' title + 'Onchain ·
    Taproot' subtitle on the left, big 24sp bold balance + 'sats'
    label on the right (same typography as NWC).
  - Loading shows an orange CircularProgressIndicator in the balance
    slot. Error / no-backend states render an em-dash placeholder
    with a small status caption.
  - Address block: 'Your Taproot address' caption + monospace
    truncated address.
  - Action row: outlined 'Copy' with copy icon on the left, filled
    bitcoin-orange 'Send' with send icon on the right — same 36dp
    height + RoundedCornerShape(8.dp) as the NWC card actions.
This commit is contained in:
Claude
2026-05-16 20:21:25 +00:00
parent e4b8e7ccc8
commit 627b75681f
@@ -20,14 +20,25 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
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.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.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -40,13 +51,20 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
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 androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.components.util.setText
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.nipBCOnchainZaps.taproot.TaprootAddress import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -54,13 +72,13 @@ import kotlinx.coroutines.withContext
import java.text.NumberFormat import java.text.NumberFormat
/** /**
* "Bitcoin" section card on the wallet screen, shown above the lightning NWC * "Bitcoin" card on the wallet screen, shown above the lightning NWC wallet
* wallet list. Every account has exactly one Taproot address (derived from * list. Visually mirrors [WalletCard] so the two payment rails read as the
* its Nostr pubkey via NIP-BC / BIP-341), so this is always a single card — * same kind of object; bitcoin-orange accent in place of NWC's primary
* not a list. * (purple) keeps the rails distinct at a glance.
* *
* Phase C scope: derive + display + copy address. Balance, recent zaps, send, * Every account has exactly one Taproot address (derived from its Nostr
* and tap-to-detail UI come in later phases. * pubkey via NIP-BC / BIP-341), so this is always a single card — not a list.
*/ */
@Composable @Composable
fun OnchainSection( fun OnchainSection(
@@ -69,16 +87,11 @@ fun OnchainSection(
) { ) {
val pubKey = accountViewModel.account.signer.pubKey val pubKey = accountViewModel.account.signer.pubKey
// Cache the derived address — bech32m + tap-tweak is cheap but pubkey doesn't
// change for the lifetime of the screen.
val address = val address =
remember(pubKey) { remember(pubKey) {
runCatching { TaprootAddress.fromPubKey(pubKey) }.getOrNull() runCatching { TaprootAddress.fromPubKey(pubKey) }.getOrNull()
} }
// Balance fetched from the configured OnchainBackend. null = unknown / loading
// / unconfigured. We intentionally do not retry on failure here — the user can
// refresh by leaving and re-entering the screen.
var balanceSats by remember(pubKey) { mutableStateOf<Long?>(null) } var balanceSats by remember(pubKey) { mutableStateOf<Long?>(null) }
var balanceState by remember(pubKey) { mutableStateOf(BalanceState.LOADING) } var balanceState by remember(pubKey) { mutableStateOf(BalanceState.LOADING) }
@@ -102,39 +115,37 @@ fun OnchainSection(
} }
} }
val orange = MaterialTheme.colorScheme.bitcoinColor
Card( Card(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), shape = RoundedCornerShape(16.dp),
border = BorderStroke(2.dp, orange),
colors =
CardDefaults.cardColors(
containerColor = orange.copy(alpha = 0.12f),
),
) { ) {
Column( Column(modifier = Modifier.padding(16.dp)) {
modifier = Modifier.padding(16.dp), HeaderRow(
verticalArrangement = Arrangement.spacedBy(8.dp), orange = orange,
) { balanceState = balanceState,
Text( balanceSats = balanceSats,
text = "Bitcoin",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
) )
if (address == null) { if (address == null) {
Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = "Address derivation unavailable for this account.", text = "Address derivation unavailable for this account.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
BalanceRow(state = balanceState, sats = balanceSats) Spacer(modifier = Modifier.height(12.dp))
Text( AddressBlock(address = address)
text = "Your Taproot address",
style = MaterialTheme.typography.labelMedium, Spacer(modifier = Modifier.height(12.dp))
color = MaterialTheme.colorScheme.onSurfaceVariant, ActionRow(address = address, accountViewModel = accountViewModel, orange = orange)
)
Text(
text = address,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
ActionRow(address = address, accountViewModel = accountViewModel)
} }
} }
} }
@@ -143,33 +154,138 @@ fun OnchainSection(
private enum class BalanceState { LOADING, READY, ERROR, UNAVAILABLE } private enum class BalanceState { LOADING, READY, ERROR, UNAVAILABLE }
@Composable @Composable
private fun BalanceRow( private fun HeaderRow(
orange: Color,
balanceState: BalanceState,
balanceSats: Long?,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
BitcoinChip(orange)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = "Bitcoin",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Onchain · Taproot",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
BalanceBlock(state = balanceState, sats = balanceSats, orange = orange)
}
}
@Composable
private fun BitcoinChip(orange: Color) {
Box(
modifier =
Modifier
.size(28.dp)
.clip(CircleShape)
.background(color = orange),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.CurrencyBitcoin,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(18.dp),
)
}
}
@Composable
private fun BalanceBlock(
state: BalanceState, state: BalanceState,
sats: Long?, sats: Long?,
orange: Color,
) { ) {
val text = if (state == BalanceState.LOADING && sats == null) {
when (state) { CircularProgressIndicator(
BalanceState.LOADING -> { modifier = Modifier.size(24.dp),
"Loading balance…" color = orange,
} )
return
}
Column(horizontalAlignment = Alignment.End) {
when (state) {
BalanceState.READY -> { BalanceState.READY -> {
val formatted = NumberFormat.getNumberInstance().format(sats ?: 0L) val formatted =
"$formatted sats" remember(sats) {
NumberFormat.getIntegerInstance().format(sats ?: 0L)
}
Text(
text = formatted,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = orange,
)
Text(
text = "sats",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
BalanceState.ERROR -> { BalanceState.ERROR -> {
"Balance unavailable" Text(
text = "",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "unavailable",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
} }
BalanceState.UNAVAILABLE -> { BalanceState.UNAVAILABLE -> {
"Chain backend not configured" Text(
text = "",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "no backend",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
BalanceState.LOADING -> Unit
} }
}
}
@Composable
private fun AddressBlock(address: String) {
Text( Text(
text = text, text = "Your Taproot address",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = address,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
) )
} }
@@ -177,6 +293,7 @@ private fun BalanceRow(
private fun ActionRow( private fun ActionRow(
address: String, address: String,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
orange: Color,
) { ) {
val clipboard = LocalClipboard.current val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -184,16 +301,42 @@ private fun ActionRow(
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
OutlinedButton(onClick = { OutlinedButton(
scope.launch { clipboard.setText(address) } onClick = { scope.launch { clipboard.setText(address) } },
}) { modifier = Modifier.height(36.dp),
Text("Copy address") shape = RoundedCornerShape(8.dp),
) {
Icon(
symbol = MaterialSymbols.ContentCopy,
contentDescription = null,
modifier = Modifier.size(14.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text("Copy", style = MaterialTheme.typography.bodySmall)
} }
Button(onClick = { showSendDialog = true }) {
Text("Send") Spacer(modifier = Modifier.weight(1f))
Button(
onClick = { showSendDialog = true },
modifier = Modifier.height(36.dp),
shape = RoundedCornerShape(8.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = orange,
contentColor = Color.White,
),
) {
Icon(
symbol = MaterialSymbols.AutoMirrored.Send,
contentDescription = null,
modifier = Modifier.size(14.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text("Send", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold)
} }
} }