This commit is contained in:
2026-06-02 19:28:12 +02:00
parent 8c5c08b93d
commit e1832f2d14
3 changed files with 145 additions and 143 deletions
+1
View File
@@ -6,6 +6,7 @@
<w>Tink</w> <w>Tink</w>
<w>amboss</w> <w>amboss</w>
<w>hkdf</w> <w>hkdf</w>
<w>inflight</w>
<w>lnbc</w> <w>lnbc</w>
<w>lnbits</w> <w>lnbits</w>
<w>lnurlp</w> <w>lnurlp</w>
@@ -2,7 +2,6 @@ package com.bitcointxoko.gudariwallet.ui
import android.content.ClipData import android.content.ClipData
import android.content.Intent import android.content.Intent
import android.util.Log
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -32,7 +31,7 @@ import androidx.core.net.toUri
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import com.bitcointxoko.gudariwallet.util.feePpm import com.bitcointxoko.gudariwallet.util.feePpm
import com.bitcointxoko.gudariwallet.util.formatFiatForSats // ← new import import com.bitcointxoko.gudariwallet.util.formatFiatForSats
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// ── List screen ────────────────────────────────────────────────────────────── // ── List screen ──────────────────────────────────────────────────────────────
@@ -156,6 +155,21 @@ fun HistoryScreen(
} }
} }
// ── Payment color helpers ─────────────────────────────────────────────────────
private fun isPendingStatus(status: String): Boolean =
status.lowercase() in setOf("pending", "in_flight", "inflight")
@Composable
private fun paymentAmountColor(status: String, isOutgoing: Boolean): Color {
val semantic = semanticColors()
return when {
isPendingStatus(status) -> MaterialTheme.colorScheme.onSurfaceVariant // muted
isOutgoing -> MaterialTheme.colorScheme.onSurface // neutral
else -> semantic.success // green
}
}
// ── Single row ─────────────────────────────────────────────────────────────── // ── Single row ───────────────────────────────────────────────────────────────
@Composable @Composable
@@ -168,16 +182,8 @@ private fun PaymentRow(
val semantic = semanticColors() val semantic = semanticColors()
// ── Pending detection ──────────────────────────────────────────────────── // ── Pending detection ────────────────────────────────────────────────────
val isPending = payment.status.lowercase() in setOf("pending", "in_flight", "inflight") val isPending = isPendingStatus(payment.status)
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
// bitcoin.design: green for received, neutral for sent, muted for pending.
val amountColor = when {
isPending -> MaterialTheme.colorScheme.onSurfaceVariant
payment.isOutgoing -> MaterialTheme.colorScheme.onSurface
else -> semantic.success
}
val amountPrefix = if (payment.isOutgoing) "" else "+"
val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward
else Icons.Default.ArrowDownward else Icons.Default.ArrowDownward
@@ -235,29 +241,15 @@ private fun PaymentRow(
} }
} }
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
AmountWithFiatColumn(
// Right: sats (primary) + fiat (secondary) — right-aligned amountSat = payment.amountSat,
Column(horizontalAlignment = Alignment.End) { isOutgoing = payment.isOutgoing,
Text( amountColor = amountColor,
text = "$amountPrefix${payment.amountSat} sats", fiatLine = fiatLine
style = MaterialTheme.typography.bodyMedium.copy( )
fontFamily = FontFamily.Monospace
),
color = amountColor
)
if (fiatLine != null) {
Spacer(Modifier.height(1.dp))
Text(
text = fiatLine,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
)
}
}
} }
} }
// ── Detail screen ──────────────────────────────────────────────────────────── // ── Detail screen ────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -354,13 +346,8 @@ private fun PaymentDetailContent(
fiatSatsPerUnit : Double?, // ← new fiatSatsPerUnit : Double?, // ← new
modifier : Modifier = Modifier modifier : Modifier = Modifier
) { ) {
val isPending = payment.status.lowercase() in setOf("pending", "in_flight", "inflight") val isPending = isPendingStatus(payment.status)
val semantic = semanticColors() val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
val amountColor = when {
isPending -> MaterialTheme.colorScheme.onSurfaceVariant // muted
payment.isOutgoing -> MaterialTheme.colorScheme.onSurface // neutral
else -> semantic.success // green
}
val context = LocalContext.current val context = LocalContext.current
val clipboard = LocalClipboard.current val clipboard = LocalClipboard.current
@@ -407,24 +394,14 @@ private fun PaymentDetailContent(
modifier = Modifier.size(36.dp) modifier = Modifier.size(36.dp)
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
AmountWithFiatColumn(
// Primary: sats amountSat = payment.amountSat,
Text( isOutgoing = payment.isOutgoing,
text = "${if (payment.isOutgoing) "" else "+"}${payment.amountSat} sats", amountColor = amountColor,
style = MaterialTheme.typography.headlineLarge, fiatLine = heroFiat,
color = amountColor style = MaterialTheme.typography.headlineLarge,
horizontalAlignment = Alignment.CenterHorizontally
) )
// Secondary: fiat equivalent
if (heroFiat != null) {
Spacer(Modifier.height(4.dp))
Text(
text = "$heroFiat",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
)
}
// Fee line (with fiat if available) // Fee line (with fiat if available)
if (payment.feeSat > 0) { if (payment.feeSat > 0) {
val ppm = feePpm( val ppm = feePpm(
@@ -433,7 +410,7 @@ private fun PaymentDetailContent(
) )
val feeLabel = buildString { val feeLabel = buildString {
append("Fee: ${payment.feeSat} sats") append("Fee: ${payment.feeSat} sats")
if (feeFiat != null) append(" ($feeFiat)") if (feeFiat != null) append(" ($feeFiat)")
if (ppm != null) append(" · $ppm ppm") if (ppm != null) append(" · $ppm ppm")
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
@@ -520,13 +497,7 @@ private fun PaymentDetailContent(
} }
) )
} }
} if (!payment.bolt11.isNullOrBlank()) {
}
// ── BOLT11 invoice ───────────────────────────────────────────────────
if (!payment.bolt11.isNullOrBlank()) {
item {
DetailSection(title = "Invoice") {
DetailRow( DetailRow(
label = "BOLT11", label = "BOLT11",
value = payment.bolt11, value = payment.bolt11,
@@ -542,8 +513,6 @@ private fun PaymentDetailContent(
} }
// ── Reusable section wrapper ───────────────────────────────────────────────── // ── Reusable section wrapper ─────────────────────────────────────────────────
// (unchanged — kept for completeness)
@Composable @Composable
private fun DetailSection( private fun DetailSection(
title : String, title : String,
@@ -563,8 +532,6 @@ private fun DetailSection(
} }
// ── Single label/value row ─────────────────────────────────────────────────── // ── Single label/value row ───────────────────────────────────────────────────
// (unchanged)
@Composable @Composable
private fun DetailRow( private fun DetailRow(
label : String, label : String,
@@ -625,8 +592,6 @@ private fun DetailRow(
} }
// ── Status chip ────────────────────────────────────────────────────────────── // ── Status chip ──────────────────────────────────────────────────────────────
// (unchanged)
@Composable @Composable
private fun StatusChip(status: String) { private fun StatusChip(status: String) {
val semantic = semanticColors() val semantic = semanticColors()
@@ -675,3 +640,30 @@ private fun formatTimestamp(time: String): String {
.format(odt) .format(odt)
}.getOrDefault(time) }.getOrDefault(time)
} }
@Composable
private fun AmountWithFiatColumn(
amountSat : Long,
isOutgoing : Boolean,
amountColor : Color,
fiatLine : String?,
style : androidx.compose.ui.text.TextStyle = MaterialTheme.typography.bodyMedium,
horizontalAlignment: Alignment.Horizontal = Alignment.End
) {
val prefix = if (isOutgoing) "" else "+"
Column(horizontalAlignment = horizontalAlignment) {
Text(
text = "$prefix$amountSat sats",
style = style.copy(fontFamily = FontFamily.Monospace),
color = amountColor
)
if (fiatLine != null) {
Spacer(Modifier.height(if (horizontalAlignment == Alignment.CenterHorizontally) 4.dp else 1.dp))
Text(
text = fiatLine,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
)
}
}
}
@@ -3,6 +3,7 @@ package com.bitcointxoko.gudariwallet.ui
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.combinedClickable
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -10,14 +11,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.ui.draw.alpha
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
@@ -29,7 +27,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import com.bitcointxoko.gudariwallet.util.formatFiat import com.bitcointxoko.gudariwallet.util.formatFiat
import kotlinx.coroutines.delay import androidx.compose.ui.text.font.FontFamily
@@ -43,18 +41,6 @@ fun HomeTab(
val balanceHidden by vm.balanceHidden.collectAsState() val balanceHidden by vm.balanceHidden.collectAsState()
var eyeVisible by remember { mutableStateOf(false) }
LaunchedEffect(balanceHidden) {
eyeVisible = true
delay(1_500)
eyeVisible = false
}
val eyeAlpha by animateFloatAsState(
targetValue = if (eyeVisible) 1f else 0f,
animationSpec = tween(durationMillis = 400),
label = "eyeAlpha"
)
val isRefreshing = balanceState is BalanceState.Loading val isRefreshing = balanceState is BalanceState.Loading
|| (balanceState as? BalanceState.Success)?.isRefreshing == true || (balanceState as? BalanceState.Success)?.isRefreshing == true
@@ -96,9 +82,7 @@ fun HomeTab(
} }
} }
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) { ) {
Icon( Icon(
imageVector = if (balanceHidden) imageVector = if (balanceHidden)
@@ -109,14 +93,15 @@ fun HomeTab(
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier modifier = Modifier
.size(20.dp) .size(20.dp)
.alpha(eyeAlpha)
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( Text(
text = if (balanceHidden) "••••••" else "${s.sats}", text = if (balanceHidden) "••••••" else formatSats(s.sats),
style = MaterialTheme.typography.displayLarge, style = MaterialTheme.typography.displayLarge.copy(
fontFamily = FontFamily.Monospace
),
color = if (balanceHidden) color = if (balanceHidden)
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
else else
@@ -124,74 +109,93 @@ fun HomeTab(
maxLines = 1, maxLines = 1,
softWrap = false, softWrap = false,
modifier = Modifier modifier = Modifier
.weight(1f, fill = false) .widthIn(max = 280.dp)
.clickable( .combinedClickable(
onClick = { vm.toggleBalanceVisibility() }, onClick = { if (!balanceHidden) vm.toggleBalanceVisibility() },
onClickLabel = if (balanceHidden) "Reveal balance" else "Hide balance", onClickLabel = if (balanceHidden) null else "Hide balance",
onLongClick = { if (balanceHidden) vm.toggleBalanceVisibility() },
onLongClickLabel = if (balanceHidden) "Reveal balance" else null,
indication = null, indication = null,
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() }
) )
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Spacer(Modifier.size(20.dp)) Text(
text = "sats",
style = MaterialTheme.typography.titleMedium.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.width(8.dp))
} }
Text(
text = "sats",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
Row( val currency = selectedCurrency
verticalAlignment = Alignment.CenterVertically, if (currency != null) {
horizontalArrangement = Arrangement.Center // ── Fiat amount + tappable currency code ──────────────────────────
) {
if (s.fiatAmount != null && s.fiatCurrency != null) {
Text(
text = if (balanceHidden) "≈ ••••"
else "${formatFiat(s.fiatAmount, s.fiatCurrency)}",
style = MaterialTheme.typography.bodyMedium,
color = if (balanceHidden)
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
maxLines = 1,
softWrap = false
)
Spacer(Modifier.width(2.dp))
}
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier horizontalArrangement = Arrangement.Center
.clickable(
onClick = { showCurrencyPicker = true },
onClickLabel = "Change currency",
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
.padding(start = 0.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)
) { ) {
Text( if (s.fiatAmount != null && s.fiatCurrency != null) {
text = selectedCurrency ?: "", Text(
style = MaterialTheme.typography.bodyMedium, text = if (balanceHidden) "••••"
color = if (selectedCurrency != null) else formatFiat(s.fiatAmount, s.fiatCurrency),
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f) style = MaterialTheme.typography.bodyMedium.copy(
else fontFamily = FontFamily.Monospace
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) ),
) color = if (balanceHidden)
// Triangle only shown when null — signals "tap to set a currency" MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
if (selectedCurrency == null) { else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
maxLines = 1,
softWrap = false
)
Spacer(Modifier.width(4.dp))
}
// Currency code — always shown when a currency is selected, tappable to change
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(
onClick = { showCurrencyPicker = true },
onClickLabel = "Change currency",
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
.padding(horizontal = 4.dp, vertical = 4.dp)
) {
Text(
text = currency,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
)
Spacer(Modifier.width(2.dp)) Spacer(Modifier.width(2.dp))
Icon( Icon(
imageVector = Icons.Filled.ArrowDropDown, imageVector = Icons.Filled.ArrowDropDown,
contentDescription = "Change currency", contentDescription = "Change currency",
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)
) )
} }
} }
} else {
// ── No currency selected — ghost prompt ───────────────────────────
TextButton(
onClick = { showCurrencyPicker = true },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
) {
Text(
text = "+ Show fiat",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
)
}
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
if (!s.isRefreshing) { if (!s.isRefreshing) {
@@ -231,8 +235,10 @@ fun HomeTab(
is BalanceState.Error -> { is BalanceState.Error -> {
if (s.staleSats != null) { if (s.staleSats != null) {
Text( Text(
text = if (balanceHidden) "••••••" else "${s.staleSats}", text = if (balanceHidden) "••••••" else formatSats(s.staleSats),
style = MaterialTheme.typography.displayLarge, style = MaterialTheme.typography.displayLarge.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
) )
Text( Text(
@@ -264,6 +270,9 @@ fun HomeTab(
} }
} }
private fun formatSats(sats: Long): String =
"%,d".format(sats).replace(',', ' ')
private fun formatLastUpdated(epochMs: Long): String { private fun formatLastUpdated(epochMs: Long): String {
val ageMs = System.currentTimeMillis() - epochMs val ageMs = System.currentTimeMillis() - epochMs
return when { return when {