rename example
This commit is contained in:
@@ -0,0 +1,677 @@
|
||||
package com.bitcointxoko.gudariwallet.ui
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.outlined.ContentCopy
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
||||
import com.bitcointxoko.gudariwallet.util.feePpm
|
||||
import com.bitcointxoko.gudariwallet.util.formatFiatForSats // ← new import
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// ── List screen ──────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HistoryScreen(
|
||||
vm: HistoryViewModel,
|
||||
onClose: () -> Unit,
|
||||
onPaymentClick: (PaymentRecord) -> Unit
|
||||
) {
|
||||
val state by vm.state.collectAsState()
|
||||
val fiatCurrency by vm.fiatCurrency.collectAsState()
|
||||
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("History") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Close"
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { vm.refresh() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
when (val s = state) {
|
||||
|
||||
is HistoryState.Loading -> {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
|
||||
is HistoryState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = s.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(onClick = { vm.refresh() }) { Text("Retry") }
|
||||
}
|
||||
}
|
||||
|
||||
is HistoryState.Success -> {
|
||||
if (s.payments.isEmpty()) {
|
||||
Text(
|
||||
text = "No payments yet.",
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
if (s.isRefreshing) {
|
||||
item {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(
|
||||
items = s.payments,
|
||||
key = { it.paymentHash }
|
||||
) { payment ->
|
||||
PaymentRow(
|
||||
payment = payment,
|
||||
fiatCurrency = fiatCurrency,
|
||||
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||
onClick = { onPaymentClick(payment) }
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
|
||||
if (s.canLoadMore) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
OutlinedButton(onClick = { vm.loadMore() }) {
|
||||
Text("More")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single row ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun PaymentRow(
|
||||
payment : PaymentRecord,
|
||||
fiatCurrency : String?,
|
||||
fiatSatsPerUnit : Double?,
|
||||
onClick : () -> Unit
|
||||
) {
|
||||
val semantic = semanticColors()
|
||||
|
||||
// ── Pending detection ────────────────────────────────────────────────────
|
||||
val isPending = payment.status.lowercase() in setOf("pending", "in_flight", "inflight")
|
||||
|
||||
// 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
|
||||
else Icons.Default.ArrowDownward
|
||||
|
||||
// Fiat secondary line — only shown when rate is available
|
||||
val fiatLine: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
|
||||
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0)
|
||||
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency)
|
||||
else null
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Direction icon — tinted to match amount color
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = if (payment.isOutgoing) "Sent" else "Received",
|
||||
tint = amountColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
// Left: memo + timestamp + optional pending badge
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = payment.memo?.takeIf { it.isNotBlank() } ?: "No memo",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = formatTimestamp(payment.time),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
// ── Pending badge ────────────────────────────────────────────────
|
||||
if (isPending) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
color = semantic.warningContainer,
|
||||
contentColor = semantic.onWarningContainer
|
||||
) {
|
||||
Text(
|
||||
text = "Pending",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
// Right: sats (primary) + fiat (secondary) — right-aligned
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = "$amountPrefix${payment.amountSat} sats",
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PaymentDetailScreen(
|
||||
payment: PaymentRecord,
|
||||
vm: HistoryViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val detailState by vm.detailState.collectAsState()
|
||||
val fiatCurrency by vm.fiatCurrency.collectAsState()
|
||||
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
||||
|
||||
LaunchedEffect(payment.checkingId) { vm.loadDetail(payment.checkingId, record = payment) }
|
||||
DisposableEffect(Unit) { onDispose { vm.clearDetail() } }
|
||||
|
||||
val enriched: PaymentRecord = when (val s = detailState) {
|
||||
is DetailState.Success -> {
|
||||
val detail = s.detail.details
|
||||
when {
|
||||
detail == null -> payment
|
||||
else -> detail.copy(
|
||||
memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo,
|
||||
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11
|
||||
)
|
||||
}
|
||||
}
|
||||
is DetailState.Partial -> s.record
|
||||
else -> payment
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(if (payment.isOutgoing) "Outgoing payment" else "Incoming payment") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
when (detailState) {
|
||||
is DetailState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) { CircularProgressIndicator() }
|
||||
}
|
||||
|
||||
is DetailState.Error -> {
|
||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Could not load full details",
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
PaymentDetailContent(
|
||||
payment = enriched,
|
||||
vm = vm,
|
||||
fiatCurrency = fiatCurrency,
|
||||
fiatSatsPerUnit = fiatSatsPerUnit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
PaymentDetailContent(
|
||||
payment = enriched,
|
||||
vm = vm,
|
||||
fiatCurrency = fiatCurrency,
|
||||
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||
modifier = Modifier.padding(padding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Detail content ───────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun PaymentDetailContent(
|
||||
payment : PaymentRecord,
|
||||
vm : HistoryViewModel,
|
||||
fiatCurrency : String?, // ← new
|
||||
fiatSatsPerUnit : Double?, // ← new
|
||||
modifier : Modifier = Modifier
|
||||
) {
|
||||
val isPending = payment.status.lowercase() in setOf("pending", "in_flight", "inflight")
|
||||
val semantic = semanticColors()
|
||||
val amountColor = when {
|
||||
isPending -> MaterialTheme.colorScheme.onSurfaceVariant // muted
|
||||
payment.isOutgoing -> MaterialTheme.colorScheme.onSurface // neutral
|
||||
else -> semantic.success // green
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val destinationMap by vm.destinationState.collectAsState()
|
||||
val bolt11 = payment.bolt11?.takeIf { it.isNotBlank() && payment.isOutgoing }
|
||||
LaunchedEffect(payment.paymentHash) {
|
||||
if (bolt11 != null) vm.resolveDestination(bolt11, payment.paymentHash)
|
||||
}
|
||||
val pubkey = destinationMap[payment.paymentHash]
|
||||
val aliases by vm.nodeAliases.collectAsState()
|
||||
|
||||
// Pre-compute fiat strings for hero + fee
|
||||
val heroFiat: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
|
||||
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0)
|
||||
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency)
|
||||
else null
|
||||
}
|
||||
val feeFiat: String? = remember(payment.feeSat, fiatSatsPerUnit, fiatCurrency) {
|
||||
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.feeSat > 0)
|
||||
formatFiatForSats(payment.feeSat, fiatSatsPerUnit, fiatCurrency)
|
||||
else null
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
|
||||
// ── Hero amount ──────────────────────────────────────────────────────
|
||||
item {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward
|
||||
else Icons.Default.ArrowDownward,
|
||||
contentDescription = null,
|
||||
tint = amountColor,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Primary: sats
|
||||
Text(
|
||||
text = "${if (payment.isOutgoing) "−" else "+"}${payment.amountSat} sats",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = amountColor
|
||||
)
|
||||
|
||||
// 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)
|
||||
if (payment.feeSat > 0) {
|
||||
val ppm = feePpm(
|
||||
amountMsat = kotlin.math.abs(payment.amountMsat),
|
||||
feeMsat = kotlin.math.abs(payment.feeMsat)
|
||||
)
|
||||
val feeLabel = buildString {
|
||||
append("Fee: ${payment.feeSat} sats")
|
||||
if (feeFiat != null) append(" (≈ $feeFiat)")
|
||||
if (ppm != null) append(" · $ppm ppm")
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = feeLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
StatusChip(status = payment.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Basic info ───────────────────────────────────────────────────────
|
||||
item {
|
||||
DetailSection(title = "Details") {
|
||||
DetailRow("Date", formatTimestamp(payment.time))
|
||||
DetailRow("Memo", payment.memo?.takeIf { it.isNotBlank() } ?: "—")
|
||||
if (payment.extra?.tag != null) {
|
||||
DetailRow("Type", payment.extra.tag.uppercase())
|
||||
}
|
||||
if (payment.extra?.comment?.isNotBlank() == true) {
|
||||
DetailRow("Comment", payment.extra.comment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Success action (LNURL) ───────────────────────────────────────────
|
||||
payment.extra?.successAction?.let { action ->
|
||||
item {
|
||||
DetailSection(title = "Success Action") {
|
||||
action.message?.let { DetailRow("Message", it) }
|
||||
action.url?.let { DetailRow("URL", it) }
|
||||
action.description?.let { DetailRow("Description", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Technical ────────────────────────────────────────────────────────
|
||||
item {
|
||||
DetailSection(title = "Technical") {
|
||||
DetailRow(
|
||||
label = "Payment Hash",
|
||||
value = payment.paymentHash,
|
||||
monospace = true,
|
||||
copyable = true,
|
||||
onCopy = { scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("Payment Hash", payment.paymentHash))) } }
|
||||
)
|
||||
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
|
||||
DetailRow(
|
||||
label = "Preimage",
|
||||
value = payment.preimage,
|
||||
monospace = true,
|
||||
copyable = true,
|
||||
onCopy = { scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("Preimage", payment.preimage))) } }
|
||||
)
|
||||
}
|
||||
if (pubkey != null) {
|
||||
val ambossUrl = "https://amboss.space/node/$pubkey"
|
||||
val label = aliases[pubkey] ?: "${pubkey.take(8)}…${pubkey.takeLast(8)}"
|
||||
DetailRow(
|
||||
label = "Destination",
|
||||
value = label,
|
||||
valueColor = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
onValueClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(
|
||||
onClick = { scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("node_id", pubkey))) } },
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ContentCopy,
|
||||
contentDescription = "Copy node ID",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── BOLT11 invoice ───────────────────────────────────────────────────
|
||||
if (!payment.bolt11.isNullOrBlank()) {
|
||||
item {
|
||||
DetailSection(title = "Invoice") {
|
||||
DetailRow(
|
||||
label = "BOLT11",
|
||||
value = payment.bolt11,
|
||||
monospace = true,
|
||||
copyable = true,
|
||||
maxLines = 3,
|
||||
onCopy = { scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("BOLT11", payment.bolt11))) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reusable section wrapper ─────────────────────────────────────────────────
|
||||
// (unchanged — kept for completeness)
|
||||
|
||||
@Composable
|
||||
private fun DetailSection(
|
||||
title : String,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single label/value row ───────────────────────────────────────────────────
|
||||
// (unchanged)
|
||||
|
||||
@Composable
|
||||
private fun DetailRow(
|
||||
label : String,
|
||||
value : String,
|
||||
monospace : Boolean = false,
|
||||
copyable : Boolean = false,
|
||||
onCopy : (() -> Unit)? = null,
|
||||
maxLines : Int = Int.MAX_VALUE,
|
||||
valueColor : Color = Color.Unspecified,
|
||||
textDecoration : TextDecoration? = null,
|
||||
onValueClick : (() -> Unit)? = null,
|
||||
trailingContent: (@Composable () -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (copyable && onCopy != null) Modifier.clickable(onClick = onCopy) else Modifier)
|
||||
.padding(vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(0.35f)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.weight(0.65f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = value,
|
||||
style = if (monospace)
|
||||
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, textDecoration = textDecoration)
|
||||
else
|
||||
MaterialTheme.typography.bodySmall.copy(textDecoration = textDecoration),
|
||||
color = valueColor,
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = false)
|
||||
.then(if (onValueClick != null) Modifier.clickable(onClick = onValueClick) else Modifier),
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (copyable) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy $label",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
trailingContent?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status chip ──────────────────────────────────────────────────────────────
|
||||
// (unchanged)
|
||||
|
||||
@Composable
|
||||
private fun StatusChip(status: String) {
|
||||
val semantic = semanticColors()
|
||||
|
||||
val (containerColor, contentColor) = when (status.lowercase()) {
|
||||
"success", "complete", "paid" ->
|
||||
semantic.successContainer to semantic.onSuccessContainer
|
||||
|
||||
"pending", "in_flight", "inflight" ->
|
||||
semantic.warningContainer to semantic.onWarningContainer
|
||||
|
||||
"failed", "error", "expired" ->
|
||||
semantic.errorContainer to semantic.onErrorContainer
|
||||
|
||||
else ->
|
||||
MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = containerColor,
|
||||
contentColor = contentColor
|
||||
) {
|
||||
Text(
|
||||
text = status.replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun formatTimestamp(time: String): String {
|
||||
return runCatching {
|
||||
val epochSeconds = time.toLong()
|
||||
java.time.format.DateTimeFormatter
|
||||
.ofPattern("dd MMM yyyy, HH:mm")
|
||||
.withZone(java.time.ZoneId.systemDefault())
|
||||
.format(java.time.Instant.ofEpochSecond(epochSeconds))
|
||||
}.recoverCatching {
|
||||
val odt = java.time.OffsetDateTime.parse(time)
|
||||
java.time.format.DateTimeFormatter
|
||||
.ofPattern("dd MMM yyyy, HH:mm")
|
||||
.withZone(java.time.ZoneId.systemDefault())
|
||||
.format(odt)
|
||||
}.getOrDefault(time)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.bitcointxoko.gudariwallet.ui
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
|
||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
||||
import com.bitcointxoko.gudariwallet.data.WalletRepository
|
||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class HistoryViewModel(
|
||||
private val repo : WalletRepository,
|
||||
private val aliasRepo : NodeAliasRepository,
|
||||
private val paymentCache : PaymentCacheRepository,
|
||||
val fiatCurrency : StateFlow<String?>, // passed from WalletViewModel.selectedCurrency
|
||||
val fiatSatsPerUnit : StateFlow<Double?> // passed from WalletViewModel.fiatSatsPerUnit
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<HistoryState>(HistoryState.Loading)
|
||||
val state: StateFlow<HistoryState> = _state
|
||||
|
||||
private var currentOffset = 0
|
||||
private var loadJob: Job? = null
|
||||
|
||||
init {
|
||||
// Cache read moved to IO dispatcher — never blocks the main thread,
|
||||
// so navigation to HistoryScreen is instant on tap.
|
||||
viewModelScope.launch {
|
||||
val cached = withContext(Dispatchers.IO) {
|
||||
paymentCache.loadCachedPayments()
|
||||
}
|
||||
if (cached.isNotEmpty()) {
|
||||
Log.d("HistoryViewModel", "PAYMENTS [INIT ] Showing ${cached.size} cached payments immediately")
|
||||
_state.value = HistoryState.Success(
|
||||
payments = cached,
|
||||
canLoadMore = cached.size >= WalletConstants.PAYMENTS_PAGE_SIZE,
|
||||
isRefreshing = true
|
||||
)
|
||||
} else {
|
||||
Log.d("HistoryViewModel", "PAYMENTS [INIT ] No cache — cold load")
|
||||
}
|
||||
loadPage()
|
||||
}
|
||||
// Note: no loadFiatRate() here — fiatSatsPerUnit is owned by WalletViewModel
|
||||
// and already kept up to date. Zero extra network calls.
|
||||
}
|
||||
|
||||
// ── Payments list ─────────────────────────────────────────────────────────
|
||||
|
||||
fun refresh() {
|
||||
loadJob?.cancel()
|
||||
currentOffset = 0
|
||||
val current = _state.value
|
||||
if (current is HistoryState.Success) {
|
||||
_state.value = current.copy(isRefreshing = true)
|
||||
} else {
|
||||
_state.value = HistoryState.Loading
|
||||
}
|
||||
loadPage()
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
if (loadJob?.isActive == true) return
|
||||
val current = _state.value
|
||||
if (current is HistoryState.Success && !current.canLoadMore) return
|
||||
loadPage()
|
||||
}
|
||||
|
||||
private fun loadPage() {
|
||||
loadJob = viewModelScope.launch {
|
||||
runCatching {
|
||||
repo.getPayments(
|
||||
offset = currentOffset,
|
||||
limit = WalletConstants.PAYMENTS_PAGE_SIZE
|
||||
)
|
||||
}.onSuccess { newPage ->
|
||||
val canLoadMore = newPage.size >= WalletConstants.PAYMENTS_PAGE_SIZE
|
||||
val existing = (_state.value as? HistoryState.Success)?.payments ?: emptyList()
|
||||
val combined = if (currentOffset == 0) newPage else existing + newPage
|
||||
val deduplicated = combined.distinctBy { it.paymentHash }
|
||||
currentOffset += newPage.size
|
||||
|
||||
val previousCount = existing.size
|
||||
val newCount = deduplicated.size
|
||||
if (currentOffset <= WalletConstants.PAYMENTS_PAGE_SIZE) {
|
||||
when {
|
||||
previousCount == 0 -> Log.d("HistoryViewModel", "PAYMENTS [NETWORK ] Cold load — got ${newPage.size} payments")
|
||||
newCount > previousCount -> Log.d("HistoryViewModel", "PAYMENTS [NETWORK ] ${newCount - previousCount} new payment(s) since cache")
|
||||
else -> Log.d("HistoryViewModel", "PAYMENTS [NETWORK ] Cache was up to date — no new payments")
|
||||
}
|
||||
}
|
||||
|
||||
_state.value = HistoryState.Success(
|
||||
payments = deduplicated,
|
||||
canLoadMore = canLoadMore,
|
||||
isRefreshing = false
|
||||
)
|
||||
|
||||
if (currentOffset <= WalletConstants.PAYMENTS_PAGE_SIZE) {
|
||||
paymentCache.savePayments(deduplicated)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
val current = _state.value
|
||||
if (current is HistoryState.Success) {
|
||||
Log.w("HistoryViewModel", "PAYMENTS [NET ERROR ] Keeping cached list — ${e.message}")
|
||||
_state.value = current.copy(isRefreshing = false)
|
||||
} else {
|
||||
Log.w("HistoryViewModel", "PAYMENTS [NET ERROR ] No cache to fall back on — ${e.message}")
|
||||
_state.value = HistoryState.Error(e.message ?: "Failed to load payments")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Payment detail ────────────────────────────────────────────────────────
|
||||
|
||||
private val _detailState = MutableStateFlow<DetailState>(DetailState.Idle)
|
||||
val detailState: StateFlow<DetailState> = _detailState
|
||||
|
||||
fun loadDetail(checkingId: String, record: PaymentRecord? = null) {
|
||||
paymentCache.getCachedDetail(checkingId)?.let {
|
||||
Log.d("HistoryViewModel", "PAYMENTS [DETAIL HIT] $checkingId — instant from cache")
|
||||
_detailState.value = DetailState.Success(it)
|
||||
return
|
||||
}
|
||||
|
||||
if (record != null) {
|
||||
Log.d("HistoryViewModel", "PAYMENTS [DETAIL PAR] $checkingId — showing partial from PaymentRecord")
|
||||
_detailState.value = DetailState.Partial(record)
|
||||
} else {
|
||||
Log.d("HistoryViewModel", "PAYMENTS [DETAIL LOD] $checkingId — no record, showing spinner")
|
||||
_detailState.value = DetailState.Loading
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
repo.getPaymentDetail(checkingId)
|
||||
}.onSuccess { detail ->
|
||||
Log.d("HistoryViewModel", "PAYMENTS [DETAIL NET] $checkingId — fetched from network")
|
||||
paymentCache.saveDetail(checkingId, detail)
|
||||
_detailState.value = DetailState.Success(detail)
|
||||
}.onFailure { e ->
|
||||
Log.w("HistoryViewModel", "PAYMENTS [DETAIL ERR] $checkingId — ${e.message}")
|
||||
if (_detailState.value !is DetailState.Partial) {
|
||||
_detailState.value = DetailState.Error(e.message ?: "Failed to load payment")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearDetail() {
|
||||
_detailState.value = DetailState.Idle
|
||||
}
|
||||
|
||||
// ── Destination pubkey + alias resolution ─────────────────────────────────
|
||||
|
||||
val nodeAliases: StateFlow<Map<String, String>> = aliasRepo.aliases
|
||||
|
||||
private val _destinationState = MutableStateFlow<Map<String, String?>>(emptyMap())
|
||||
val destinationState: StateFlow<Map<String, String?>> = _destinationState
|
||||
|
||||
fun resolveDestination(bolt11: String, paymentHash: String) {
|
||||
if (_destinationState.value.containsKey(paymentHash)) return
|
||||
viewModelScope.launch {
|
||||
val pubkey = runCatching { repo.decodeBolt11(bolt11) }
|
||||
.getOrNull()
|
||||
?.payee
|
||||
_destinationState.update { it + (paymentHash to pubkey) }
|
||||
if (pubkey != null) aliasRepo.resolve(pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Factory ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class HistoryViewModelFactory(
|
||||
private val repo : WalletRepository,
|
||||
private val aliasRepo : NodeAliasRepository,
|
||||
private val paymentCache : PaymentCacheRepository,
|
||||
private val fiatCurrency : StateFlow<String?>,
|
||||
private val fiatSatsPerUnit: StateFlow<Double?>
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return HistoryViewModel(
|
||||
repo = repo,
|
||||
aliasRepo = aliasRepo,
|
||||
paymentCache = paymentCache,
|
||||
fiatCurrency = fiatCurrency,
|
||||
fiatSatsPerUnit = fiatSatsPerUnit
|
||||
) as T
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package com.bitcointxoko.gudariwallet.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.interaction.MutableInteractionSource
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import com.bitcointxoko.gudariwallet.util.formatFiat
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeTab(
|
||||
vm: WalletViewModel,
|
||||
onHistoryClick: () -> Unit
|
||||
) {
|
||||
val balanceState by vm.balanceState.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
|
||||
|| (balanceState as? BalanceState.Success)?.isRefreshing == true
|
||||
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = { vm.refreshBalance() },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
when (val s = balanceState) {
|
||||
|
||||
is BalanceState.Loading -> {
|
||||
CircularProgressIndicator()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Loading balance…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
is BalanceState.Success -> {
|
||||
val selectedCurrency by vm.selectedCurrency.collectAsState()
|
||||
var showCurrencyPicker by remember { mutableStateOf(false) }
|
||||
var isLoadingCurrencies by remember { mutableStateOf(false) }
|
||||
var currencyList by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
LaunchedEffect(showCurrencyPicker) {
|
||||
if (showCurrencyPicker && currencyList.isEmpty()) {
|
||||
isLoadingCurrencies = true
|
||||
currencyList = vm.getSupportedCurrencies()
|
||||
isLoadingCurrencies = false
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (balanceHidden)
|
||||
Icons.Filled.VisibilityOff
|
||||
else
|
||||
Icons.Filled.Visibility,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.alpha(eyeAlpha)
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = if (balanceHidden) "••••••" else "${s.sats}",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = if (balanceHidden)
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
|
||||
else
|
||||
MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = false)
|
||||
.clickable(
|
||||
onClick = { vm.toggleBalanceVisibility() },
|
||||
onClickLabel = if (balanceHidden) "Reveal balance" else "Hide balance",
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Spacer(Modifier.size(20.dp))
|
||||
}
|
||||
Text(
|
||||
text = "sats",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
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(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.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(
|
||||
text = selectedCurrency ?: "—",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (selectedCurrency != null)
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||
)
|
||||
// Triangle only shown when null — signals "tap to set a currency"
|
||||
if (selectedCurrency == null) {
|
||||
Spacer(Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowDropDown,
|
||||
contentDescription = "Change currency",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (!s.isRefreshing) {
|
||||
val updatedLabel: String = remember(s.lastUpdated) {
|
||||
formatLastUpdated(s.lastUpdated)
|
||||
}
|
||||
Text(
|
||||
text = "Updated $updatedLabel",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
TextButton(onClick = onHistoryClick) {
|
||||
Text(
|
||||
text = "View History",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
if (showCurrencyPicker) {
|
||||
CurrencyPickerSheet(
|
||||
currencies = currencyList,
|
||||
selectedCurrency = selectedCurrency,
|
||||
onSelect = { code ->
|
||||
vm.setSelectedCurrency(code)
|
||||
showCurrencyPicker = false
|
||||
},
|
||||
isLoading = isLoadingCurrencies,
|
||||
onDismiss = { showCurrencyPicker = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is BalanceState.Error -> {
|
||||
if (s.staleSats != null) {
|
||||
Text(
|
||||
text = if (balanceHidden) "••••••" else "${s.staleSats}",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
)
|
||||
Text(
|
||||
text = "sats",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier.fillMaxWidth(0.85f)
|
||||
) {
|
||||
Text(
|
||||
text = if (s.staleSats != null)
|
||||
"Could not refresh — pull down to try again"
|
||||
else
|
||||
"Could not load balance — pull down to try again",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatLastUpdated(epochMs: Long): String {
|
||||
val ageMs = System.currentTimeMillis() - epochMs
|
||||
return when {
|
||||
ageMs < 60_000L -> "just now"
|
||||
ageMs < 3_600_000L -> "${ageMs / 60_000L} min ago"
|
||||
else -> {
|
||||
val formatter = java.time.format.DateTimeFormatter
|
||||
.ofPattern("HH:mm")
|
||||
.withZone(java.time.ZoneId.systemDefault())
|
||||
"at ${formatter.format(java.time.Instant.ofEpochMilli(epochMs))}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CurrencyPickerSheet(
|
||||
currencies : List<String>,
|
||||
isLoading : Boolean, // NEW — true while list is being fetched
|
||||
selectedCurrency: String?,
|
||||
onSelect : (String?) -> Unit,
|
||||
onDismiss : () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
var query by remember { mutableStateOf("") }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
// Filter list client-side — no network, instant response
|
||||
// Only active once the list is populated
|
||||
val filtered = remember(query, currencies) {
|
||||
if (query.isBlank()) currencies
|
||||
else currencies.filter { it.contains(query.trim(), ignoreCase = true) }
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────
|
||||
Text(
|
||||
text = "Select currency",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
)
|
||||
|
||||
// ── "None" option — always pinned at top, never filtered ──────────
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = "None",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (selectedCurrency == null)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = "Hide fiat equivalent",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
trailingContent = if (selectedCurrency == null) ({
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = "Selected",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}) else null,
|
||||
modifier = Modifier.clickable { onSelect(null) }
|
||||
)
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
|
||||
// ── Search field — hidden while loading ───────────────────────────
|
||||
if (!isLoading) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Search…") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Search
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSearch = { focusManager.clearFocus() }
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// ── Body — spinner OR list OR empty state ─────────────────────────
|
||||
when {
|
||||
isLoading -> {
|
||||
// Spinner while list is being fetched on first open
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 48.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
filtered.isEmpty() -> {
|
||||
// No results after search
|
||||
Text(
|
||||
text = if (query.isBlank()) "No currencies available"
|
||||
else "No currencies match \"$query\"",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = 32.dp)
|
||||
) {
|
||||
items(filtered, key = { it }) { code ->
|
||||
val isSelected = code == selectedCurrency
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = code,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (isSelected)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
trailingContent = if (isSelected) ({
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = "Selected",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}) else null,
|
||||
modifier = Modifier.clickable { onSelect(code) }
|
||||
)
|
||||
HorizontalDivider(thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.bitcointxoko.gudariwallet.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.NotificationsOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// ─── Permission state model ───────────────────────────────────────────────────
|
||||
|
||||
sealed class NotificationPermissionState {
|
||||
data object NeverAsked : NotificationPermissionState()
|
||||
data object Rationale : NotificationPermissionState() // asked once, denied
|
||||
data object PermanentlyDenied: NotificationPermissionState() // "don't ask again"
|
||||
data object Granted : NotificationPermissionState()
|
||||
}
|
||||
|
||||
// ─── Prefs helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Persists a flag so we can distinguish between:
|
||||
* - "never asked" → show the first-time request screen
|
||||
* - "asked and denied" → show rationale
|
||||
* - "permanently denied"→ send user to system settings
|
||||
*/
|
||||
object PermissionPrefs {
|
||||
private const val PREFS_NAME = "lnbits_prefs"
|
||||
private const val KEY = "notification_permission_requested"
|
||||
|
||||
fun hasRequestedBefore(context: Context): Boolean =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getBoolean(KEY, false)
|
||||
|
||||
fun markRequested(context: Context) =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putBoolean(KEY, true).apply()
|
||||
}
|
||||
|
||||
// ─── Main composable ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handles the full POST_NOTIFICATIONS runtime permission flow.
|
||||
*
|
||||
* On Android 12 and below this permission doesn't exist — we skip straight
|
||||
* to onPermissionGranted since notifications are always allowed.
|
||||
*
|
||||
* @param hasRequestedBefore Read from PermissionPrefs — distinguishes
|
||||
* "never asked" from "permanently denied"
|
||||
* @param onPermissionGranted Called when permission is granted
|
||||
* @param onPermissionDenied Called when user skips or permanently denies
|
||||
* @param onRequestedFirstTime Called after the first system prompt fires —
|
||||
* persists the flag via PermissionPrefs
|
||||
*/
|
||||
@Composable
|
||||
fun NotificationPermissionScreen(
|
||||
hasRequestedBefore: Boolean,
|
||||
onPermissionGranted: () -> Unit,
|
||||
onPermissionDenied: () -> Unit,
|
||||
onRequestedFirstTime: () -> Unit
|
||||
) {
|
||||
// Android 12 and below — POST_NOTIFICATIONS doesn't exist, skip entirely
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
LaunchedEffect(Unit) { onPermissionGranted() }
|
||||
return
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
// Tracks whether the system already granted the permission
|
||||
// (e.g. user granted it in a previous session)
|
||||
var permissionState by remember {
|
||||
mutableStateOf<NotificationPermissionState>(
|
||||
when {
|
||||
!hasRequestedBefore -> NotificationPermissionState.NeverAsked
|
||||
else -> NotificationPermissionState.Rationale
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// The system permission launcher
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
permissionState = if (granted) {
|
||||
NotificationPermissionState.Granted
|
||||
} else {
|
||||
// If we've already asked before and they denied again → permanent denial
|
||||
if (hasRequestedBefore) NotificationPermissionState.PermanentlyDenied
|
||||
else NotificationPermissionState.Rationale
|
||||
}
|
||||
onRequestedFirstTime()
|
||||
}
|
||||
|
||||
// React to state changes
|
||||
LaunchedEffect(permissionState) {
|
||||
when (permissionState) {
|
||||
is NotificationPermissionState.Granted -> onPermissionGranted()
|
||||
else -> { /* handled by UI below */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── UI ────────────────────────────────────────────────────────────────────
|
||||
when (permissionState) {
|
||||
|
||||
is NotificationPermissionState.NeverAsked,
|
||||
is NotificationPermissionState.Rationale -> {
|
||||
PermissionRequestCard(
|
||||
onAllow = {
|
||||
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
},
|
||||
onSkip = onPermissionDenied
|
||||
)
|
||||
}
|
||||
|
||||
is NotificationPermissionState.PermanentlyDenied -> {
|
||||
PermanentlyDeniedCard(
|
||||
onOpenSettings = {
|
||||
// Send user to the app's system notification settings
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
}
|
||||
)
|
||||
},
|
||||
onSkip = onPermissionDenied
|
||||
)
|
||||
}
|
||||
|
||||
is NotificationPermissionState.Granted -> {
|
||||
// LaunchedEffect above handles this — show nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sub-composables ──────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun PermissionRequestCard(
|
||||
onAllow: () -> Unit,
|
||||
onSkip: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Notifications,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(72.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Stay notified of payments",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Allow notifications so you know the moment a Lightning payment arrives, even when the app is in the background.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(40.dp))
|
||||
Button(
|
||||
onClick = onAllow,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Allow Notifications")
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
TextButton(
|
||||
onClick = onSkip,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Not Now")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermanentlyDeniedCard(
|
||||
onOpenSettings: () -> Unit,
|
||||
onSkip: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.NotificationsOff,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(72.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Notifications are blocked",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "You've permanently denied notifications. To receive payment alerts, enable them in system settings.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(40.dp))
|
||||
Button(
|
||||
onClick = onOpenSettings,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Open Settings")
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
TextButton(
|
||||
onClick = onSkip,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Skip")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.bitcointxoko.gudariwallet.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitcointxoko.gudariwallet.security.SecretStore
|
||||
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
secretStore: SecretStore,
|
||||
onComplete: () -> Unit
|
||||
) {
|
||||
var baseUrl by remember { mutableStateOf("https://bitcointxoko.org") }
|
||||
var invoiceKey by remember { mutableStateOf("") }
|
||||
var adminKey by remember { mutableStateOf("") }
|
||||
var adminVisible by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Connect to LNbits", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Enter your LNbits server URL and API keys.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = baseUrl,
|
||||
onValueChange = { baseUrl = it },
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("https://bitcointxoko.org") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = invoiceKey,
|
||||
onValueChange = { invoiceKey = it },
|
||||
label = { Text("Invoice Key (read-only)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
visualTransformation = PasswordVisualTransformation()
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = adminKey,
|
||||
onValueChange = { adminKey = it },
|
||||
label = { Text("Admin Key (for sending)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
visualTransformation = if (adminVisible) VisualTransformation.None
|
||||
else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { adminVisible = !adminVisible }) {
|
||||
Icon(
|
||||
imageVector = if (adminVisible) Icons.Default.VisibilityOff
|
||||
else Icons.Default.Visibility,
|
||||
contentDescription = if (adminVisible) "Hide" else "Show"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (baseUrl.isBlank() || invoiceKey.isBlank() || adminKey.isBlank()) {
|
||||
error = "All fields are required."
|
||||
return@Button
|
||||
}
|
||||
secretStore.saveCredentials(baseUrl, invoiceKey, adminKey)
|
||||
onComplete()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.bitcointxoko.gudariwallet.ui
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Shown when an invoice has been paid.
|
||||
* Displays a pulsing checkmark, the amount received, and the memo.
|
||||
*
|
||||
* @param amountSats Amount received in satoshis
|
||||
* @param memo Invoice memo
|
||||
* @param onDone Called when the user taps "Done" — navigates back to Home
|
||||
* @param onReceiveMore Called when the user taps "Receive More" — resets the screen
|
||||
*/
|
||||
@Composable
|
||||
fun PaymentSuccessScreen(
|
||||
amountSats: Long,
|
||||
memo: String,
|
||||
onDone: () -> Unit,
|
||||
onReceiveMore: () -> Unit
|
||||
) {
|
||||
// Pulsing scale animation on the checkmark icon
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||
val scale by infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 1.08f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 700, easing = EaseInOut),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "checkScale"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// ── Animated checkmark ────────────────────────────────────────────
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = "Payment received",
|
||||
tint = MaterialTheme.colorScheme.tertiary, // green role
|
||||
modifier = Modifier
|
||||
.size(96.dp)
|
||||
.scale(scale)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
// ── Title ─────────────────────────────────────────────────────────
|
||||
Text(
|
||||
text = "Payment Received!",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// ── Amount ────────────────────────────────────────────────────────
|
||||
Text(
|
||||
text = "+$amountSats sats",
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
// ── Memo ──────────────────────────────────────────────────────────
|
||||
if (memo.isNotBlank()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = memo,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(48.dp))
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────
|
||||
Button(
|
||||
onClick = onDone,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Done")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onReceiveMore,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Receive More")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.bitcointxoko.gudariwallet.ui
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
|
||||
/**
|
||||
* Launches the zxing-android-embedded scanner activity and calls [onScanned]
|
||||
* with the raw result string, or [onDismiss] if the user cancels.
|
||||
*
|
||||
* Camera permission is handled internally by the scanner activity — no
|
||||
* Accompanist boilerplate needed here.
|
||||
*/
|
||||
@Composable
|
||||
fun QrScannerScreen(
|
||||
onScanned: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val launcher = rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
val content = result.contents
|
||||
if (content != null) {
|
||||
onScanned(content)
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// Launch the scanner as soon as this composable enters the composition.
|
||||
// The scanner activity takes over the screen; when it finishes the result
|
||||
// callback above fires and we navigate away.
|
||||
LaunchedEffect(Unit) {
|
||||
val options = ScanOptions().apply {
|
||||
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
setPrompt("") // no bottom prompt text
|
||||
setBeepEnabled(false) // wallet apps are usually silent
|
||||
setOrientationLocked(true)
|
||||
}
|
||||
launcher.launch(options)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
package com.bitcointxoko.gudariwallet.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitcointxoko.gudariwallet.ui.components.UnitWheelPicker
|
||||
import com.bitcointxoko.gudariwallet.ui.receive.AmountDisplay
|
||||
import com.bitcointxoko.gudariwallet.ui.receive.QrDisplayCard
|
||||
import com.bitcointxoko.gudariwallet.util.fiatLabel
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun ReceiveScreen(vm: WalletViewModel) {
|
||||
val receiveState by vm.receiveState.collectAsStateWithLifecycle()
|
||||
val lightningAddress by vm.lightningAddress.collectAsStateWithLifecycle()
|
||||
val fiatRate by vm.fiatSatsPerUnit.collectAsStateWithLifecycle()
|
||||
val fiatCurrency by vm.selectedCurrency.collectAsStateWithLifecycle()
|
||||
|
||||
val context = LocalContext.current
|
||||
val window = (context as Activity).window
|
||||
val originalBrightness = remember { window.attributes.screenBrightness }
|
||||
var brightnessOn by remember { mutableStateOf(false) }
|
||||
var showAddress by remember { mutableStateOf(false) }
|
||||
|
||||
val onToggleBrightness = {
|
||||
val lp = window.attributes
|
||||
lp.screenBrightness = if (!brightnessOn)
|
||||
WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
|
||||
else originalBrightness
|
||||
window.attributes = lp
|
||||
brightnessOn = !brightnessOn
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
val lp = window.attributes
|
||||
lp.screenBrightness = originalBrightness
|
||||
window.attributes = lp
|
||||
vm.resetReceiveState()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (receiveState !is ReceiveState.InvoiceReady) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
when (val state = receiveState) {
|
||||
|
||||
is ReceiveState.Idle,
|
||||
is ReceiveState.AwaitingInvoice -> {
|
||||
ReceiveIdleContent(
|
||||
fiatRate = fiatRate,
|
||||
fiatCurrency = fiatCurrency,
|
||||
lightningAddress = lightningAddress,
|
||||
showAddress = showAddress,
|
||||
onToggleAddress = { showAddress = !showAddress },
|
||||
brightnessOn = brightnessOn,
|
||||
onToggleBrightness = onToggleBrightness,
|
||||
isLoading = receiveState is ReceiveState.AwaitingInvoice,
|
||||
onCreateInvoice = { amountSats, memo ->
|
||||
vm.createInvoice(amountSats, memo)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is ReceiveState.LnurlWithdrawReady -> {
|
||||
LnurlWithdrawSheet(
|
||||
state = state,
|
||||
onConfirm = { amountSats ->
|
||||
vm.executeWithdraw(
|
||||
k1 = state.k1,
|
||||
callback = state.callback,
|
||||
amountSats = amountSats,
|
||||
memo = state.defaultDescription
|
||||
)
|
||||
},
|
||||
onDismiss = { vm.resetReceiveState() }
|
||||
)
|
||||
}
|
||||
|
||||
is ReceiveState.PaymentReceived -> {
|
||||
ReceiveSuccessContent(
|
||||
amountSats = state.amountSats,
|
||||
memo = state.memo,
|
||||
fiatRate = fiatRate,
|
||||
fiatCurrency = fiatCurrency,
|
||||
onDone = { vm.resetReceiveState() }
|
||||
)
|
||||
}
|
||||
|
||||
is ReceiveState.Error -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Error",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = state.message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
OutlinedButton(onClick = { vm.resetReceiveState() }) {
|
||||
Text("Try Again")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
if (receiveState is ReceiveState.InvoiceReady) {
|
||||
ReceiveInvoiceContent(
|
||||
state = receiveState as ReceiveState.InvoiceReady,
|
||||
fiatRate = fiatRate,
|
||||
fiatCurrency = fiatCurrency,
|
||||
brightnessOn = brightnessOn,
|
||||
onToggleBrightness = onToggleBrightness,
|
||||
onReset = { vm.resetReceiveState() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun LnurlWithdrawSheet(
|
||||
state : ReceiveState.LnurlWithdrawReady,
|
||||
onConfirm: (Long) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val fixedAmount = state.minSats == state.maxSats
|
||||
|
||||
var amountText by remember { mutableStateOf(state.maxSats.toString()) }
|
||||
var amountError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun validate(): Long? {
|
||||
val parsed = amountText.trim().toLongOrNull()
|
||||
return when {
|
||||
parsed == null || parsed <= 0 -> { amountError = "Enter a valid amount"; null }
|
||||
parsed < state.minSats -> { amountError = "Minimum is ${state.minSats} sats"; null }
|
||||
parsed > state.maxSats -> { amountError = "Maximum is ${state.maxSats} sats"; null }
|
||||
else -> { amountError = null; parsed }
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Download,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Withdraw from",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = state.domain,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.defaultDescription.isNotBlank()) {
|
||||
Card(colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)) {
|
||||
Text(
|
||||
text = state.defaultDescription,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (fixedAmount) {
|
||||
Card(colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Amount",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "${state.maxSats} sats",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = amountText,
|
||||
onValueChange = { amountText = it; amountError = null },
|
||||
label = { Text("Amount (sats)") },
|
||||
supportingText = {
|
||||
if (amountError != null) Text(amountError!!, color = MaterialTheme.colorScheme.error)
|
||||
else Text("${state.minSats} – ${state.maxSats} sats")
|
||||
},
|
||||
isError = amountError != null,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val amount = if (fixedAmount) state.maxSats else validate()
|
||||
if (amount != null) onConfirm(amount)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Withdraw ${if (fixedAmount) "${state.maxSats} sats" else ""}")
|
||||
}
|
||||
|
||||
TextButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
private enum class AmountUnit { SATS, FIAT }
|
||||
|
||||
@Composable
|
||||
private fun ReceiveIdleContent(
|
||||
fiatRate : Double?,
|
||||
fiatCurrency : String?,
|
||||
lightningAddress : String?,
|
||||
showAddress : Boolean,
|
||||
onToggleAddress : () -> Unit,
|
||||
brightnessOn : Boolean,
|
||||
onToggleBrightness : () -> Unit,
|
||||
isLoading : Boolean,
|
||||
onCreateInvoice : (Long, String) -> Unit
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
// Wrap the toggle so that opening the address card always clears focus + keyboard
|
||||
val onToggleAddressWithDismiss = {
|
||||
if (!showAddress) {
|
||||
// About to show address — dismiss keyboard and clear focus first
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
onToggleAddress()
|
||||
}
|
||||
|
||||
var step by remember { mutableIntStateOf(1) }
|
||||
var confirmedAmountText by remember { mutableStateOf("") }
|
||||
var confirmedUnit by remember { mutableStateOf(AmountUnit.SATS) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// ── Header row ────────────────────────────────────────────────────────
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Receive",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
if (lightningAddress != null) {
|
||||
IconButton(onClick = onToggleAddressWithDismiss) { // ← use wrapped version
|
||||
Text(
|
||||
text = "@",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (showAddress) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lightning address card ────────────────────────────────────────────
|
||||
AnimatedVisibility(visible = showAddress && lightningAddress != null) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = lightningAddress!!,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
QrDisplayCard(
|
||||
content = lightningAddress!!,
|
||||
contentDescription = "Lightning address QR code",
|
||||
clipLabel = "Lightning Address",
|
||||
shareTitle = "Share Lightning Address",
|
||||
textToCopy = lightningAddress!!,
|
||||
brightnessOn = brightnessOn,
|
||||
onToggleBrightness = onToggleBrightness
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (step == 1) {
|
||||
AmountStepContent(
|
||||
fiatRate = fiatRate,
|
||||
fiatCurrency = fiatCurrency,
|
||||
addressShowing = showAddress,
|
||||
onCollapseAddress = onToggleAddressWithDismiss, // ← consistent: same wrapper
|
||||
onNext = { text, unit ->
|
||||
confirmedAmountText = text
|
||||
confirmedUnit = unit
|
||||
step = 2
|
||||
}
|
||||
)
|
||||
} else {
|
||||
val confirmedSats = when (confirmedUnit) {
|
||||
AmountUnit.SATS -> confirmedAmountText.toDouble().toLong()
|
||||
AmountUnit.FIAT -> (confirmedAmountText.toDouble() * (fiatRate ?: 1.0)).toLong()
|
||||
}
|
||||
MemoStepContent(
|
||||
confirmedSats = confirmedSats,
|
||||
fiatLabel = fiatLabel(confirmedSats, fiatRate, fiatCurrency),
|
||||
isLoading = isLoading,
|
||||
onCreateInvoice = { memo -> onCreateInvoice(confirmedSats, memo) },
|
||||
onBack = { step = 1 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun AmountStepContent(
|
||||
fiatRate : Double?,
|
||||
fiatCurrency : String?,
|
||||
addressShowing : Boolean,
|
||||
onCollapseAddress : () -> Unit,
|
||||
onNext : (amountText: String, activeUnit: AmountUnit) -> Unit
|
||||
) {
|
||||
val showFiatToggle = fiatRate != null && fiatCurrency != null
|
||||
|
||||
var amountText by remember { mutableStateOf("") }
|
||||
var amountError by remember { mutableStateOf<String?>(null) }
|
||||
var activeUnit by remember { mutableStateOf(AmountUnit.SATS) }
|
||||
|
||||
val parsedAmount = amountText.trim().toDoubleOrNull()
|
||||
val amountValid = parsedAmount != null && parsedAmount > 0
|
||||
|
||||
val conversionLabel: String? = remember(amountText, activeUnit, fiatRate, fiatCurrency) {
|
||||
if (!showFiatToggle) return@remember null
|
||||
val number = amountText.trim().toDoubleOrNull() ?: return@remember null
|
||||
when (activeUnit) {
|
||||
AmountUnit.SATS -> fiatLabel(number.toLong(), fiatRate, fiatCurrency)
|
||||
AmountUnit.FIAT -> "≈ ${(number * fiatRate!!).toLong()} sats"
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = amountText,
|
||||
onValueChange = { amountText = it; amountError = null },
|
||||
placeholder = {
|
||||
Text(if (activeUnit == AmountUnit.SATS || !showFiatToggle) "0" else "0.00")
|
||||
},
|
||||
isError = amountError != null,
|
||||
supportingText = when {
|
||||
amountError != null -> { { Text(amountError!!, color = MaterialTheme.colorScheme.error) } }
|
||||
conversionLabel != null -> { { Text(conversionLabel, color = MaterialTheme.colorScheme.onSurfaceVariant) } }
|
||||
else -> null
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.onFocusChanged { if (it.isFocused && addressShowing) onCollapseAddress() }
|
||||
)
|
||||
|
||||
if (showFiatToggle) {
|
||||
UnitWheelPicker(
|
||||
units = listOf("sats", fiatCurrency),
|
||||
selectedIndex = if (activeUnit == AmountUnit.SATS) 0 else 1,
|
||||
onIndexSelected = { newIndex ->
|
||||
val newUnit = if (newIndex == 0) AmountUnit.SATS else AmountUnit.FIAT
|
||||
if (newUnit != activeUnit) {
|
||||
activeUnit = newUnit
|
||||
amountText = ""
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (!amountValid) amountError = "Enter a valid amount"
|
||||
else { amountError = null; onNext(amountText, activeUnit) }
|
||||
},
|
||||
enabled = amountText.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Next")
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun MemoStepContent(
|
||||
confirmedSats : Long,
|
||||
fiatLabel : String?,
|
||||
isLoading : Boolean,
|
||||
onCreateInvoice : (memo: String) -> Unit,
|
||||
onBack : () -> Unit
|
||||
) {
|
||||
var memo by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
AmountDisplay(amountSats = confirmedSats, fiatLabel = fiatLabel)
|
||||
|
||||
OutlinedTextField(
|
||||
value = memo,
|
||||
onValueChange = { memo = it },
|
||||
label = { Text("Memo (optional)") },
|
||||
placeholder = { Text("What's this for?") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { onCreateInvoice(memo.trim()) },
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(if (isLoading) "Creating invoice…" else "Create Invoice")
|
||||
}
|
||||
|
||||
TextButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("← Back")
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun ReceiveInvoiceContent(
|
||||
state : ReceiveState.InvoiceReady,
|
||||
fiatRate : Double?,
|
||||
fiatCurrency : String?,
|
||||
brightnessOn : Boolean,
|
||||
onToggleBrightness : () -> Unit,
|
||||
onReset : () -> Unit
|
||||
) {
|
||||
var secondsLeft by remember {
|
||||
mutableLongStateOf(
|
||||
(state.expiresAt - System.currentTimeMillis() / 1000L).coerceAtLeast(0L)
|
||||
)
|
||||
}
|
||||
LaunchedEffect(state.expiresAt) {
|
||||
while (secondsLeft > 0L) {
|
||||
delay(1_000L)
|
||||
secondsLeft = (state.expiresAt - System.currentTimeMillis() / 1000L)
|
||||
.coerceAtLeast(0L)
|
||||
}
|
||||
}
|
||||
|
||||
val isExpired = secondsLeft == 0L
|
||||
val expiryLabel = "%d:%02d".format(secondsLeft / 60, secondsLeft % 60)
|
||||
val fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// ── Amount ────────────────────────────────────────────────────────────
|
||||
AmountDisplay(amountSats = state.amountSats, fiatLabel = fiatLabel)
|
||||
|
||||
if (state.memo.isNotBlank()) {
|
||||
Text(
|
||||
text = state.memo,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// ── QR — weight(1f) claims all remaining vertical space ───────────────
|
||||
// modifier targets the QrDisplayCard's outer Column (gives it the height)
|
||||
// qrModifier targets the Image inside (fills that height, stays square)
|
||||
QrDisplayCard(
|
||||
content = state.bolt11.uppercase(),
|
||||
contentDescription = "Lightning invoice QR code",
|
||||
clipLabel = "Invoice",
|
||||
shareTitle = "Share Invoice",
|
||||
textToCopy = state.bolt11,
|
||||
brightnessOn = brightnessOn,
|
||||
onToggleBrightness = onToggleBrightness,
|
||||
modifier = Modifier
|
||||
.weight(1f) // outer Column gets all remaining height
|
||||
.fillMaxWidth(),
|
||||
qrModifier = Modifier
|
||||
.fillMaxWidth() // Image fills the column width
|
||||
.weight(1f) // Image also takes remaining height inside QrDisplayCard's Column
|
||||
)
|
||||
|
||||
// ── Status ────────────────────────────────────────────────────────────
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (!isExpired) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Text(
|
||||
text = "Waiting for payment…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = expiryLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Invoice expired",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onReset,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(if (isExpired) "New Invoice" else "Cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReceiveSuccessContent(
|
||||
amountSats : Long,
|
||||
memo : String?,
|
||||
fiatRate : Double?,
|
||||
fiatCurrency : String?,
|
||||
onDone : () -> Unit
|
||||
) {
|
||||
val fiatLabel = fiatLabel(amountSats, fiatRate, fiatCurrency)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(72.dp)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Payment Received!", style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// AmountDisplay renders both the sats line and the fiat subtitle
|
||||
AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel)
|
||||
if (!memo.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(memo, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Done")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.bitcointxoko.gudariwallet.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentPaste
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitcointxoko.gudariwallet.ui.send.Bolt11ConfirmCard
|
||||
import com.bitcointxoko.gudariwallet.ui.send.LnurlInvoiceConfirmCard
|
||||
import com.bitcointxoko.gudariwallet.ui.send.LnurlPayForm
|
||||
import com.bitcointxoko.gudariwallet.util.SendInputDetector
|
||||
import com.bitcointxoko.gudariwallet.util.feePpm
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SendScreen(vm: WalletViewModel) {
|
||||
val sendState: SendState by vm.sendState.collectAsStateWithLifecycle()
|
||||
var input by remember { mutableStateOf("") }
|
||||
val context = LocalContext.current
|
||||
val activity = context as FragmentActivity
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Read rate directly from VM cache — same source HomeTab uses.
|
||||
// Both are null when no currency is selected; fiat display is suppressed.
|
||||
val fiatRate = vm.fiatRate // Double?
|
||||
val fiatCurrency = vm.fiatCurrency // String?
|
||||
|
||||
DisposableEffect(Unit) { onDispose { vm.resetSendState() } }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Send", style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
if (sendState !is SendState.PaymentSent) {
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = { input = SendInputDetector.normalize(it) },
|
||||
label = { Text("Invoice, LNURL, or Lightning Address") },
|
||||
placeholder = { Text("lnbc… / lnurl1… / user@domain.com") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = sendState is SendState.Idle || sendState is SendState.Error,
|
||||
singleLine = false,
|
||||
maxLines = 4,
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Go
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onGo = { if (input.isNotBlank()) vm.scan(input) }
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
when (val state = sendState) {
|
||||
is SendState.Idle -> {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val text = clipboard.getClipEntry()
|
||||
?.clipData
|
||||
?.getItemAt(0)
|
||||
?.text
|
||||
?.toString()
|
||||
if (!text.isNullOrBlank()) {
|
||||
input = SendInputDetector.normalize(text)
|
||||
vm.scan(text)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ContentPaste,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Paste")
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = { if (input.isNotBlank()) vm.scan(input) },
|
||||
enabled = input.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text("Continue") }
|
||||
}
|
||||
|
||||
is SendState.Scanning -> {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CircularProgressIndicator()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Resolving…")
|
||||
}
|
||||
|
||||
is SendState.Bolt11Decoded -> {
|
||||
Bolt11ConfirmCard(
|
||||
state = state,
|
||||
activity = activity,
|
||||
vm = vm,
|
||||
fiatRate = fiatRate,
|
||||
fiatCurrency = fiatCurrency
|
||||
)
|
||||
}
|
||||
|
||||
is SendState.LnurlReady -> {
|
||||
LnurlPayForm(
|
||||
state = state,
|
||||
vm = vm,
|
||||
fiatRate = fiatRate,
|
||||
fiatCurrency = fiatCurrency
|
||||
)
|
||||
}
|
||||
|
||||
is SendState.LnurlInvoiceReady -> {
|
||||
LnurlInvoiceConfirmCard(
|
||||
state = state,
|
||||
activity = activity,
|
||||
vm = vm,
|
||||
fiatRate = fiatRate,
|
||||
fiatCurrency = fiatCurrency
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
is SendState.Paying -> {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CircularProgressIndicator()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Sending payment…")
|
||||
}
|
||||
|
||||
is SendState.PaymentSent -> {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Text(
|
||||
text = "✓",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Payment sent!", style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "${state.amountSats} sats",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val feeText = when {
|
||||
state.feeSats == null || state.feeSats == 0L -> "No fees"
|
||||
else -> {
|
||||
val ppm = feePpm(
|
||||
amountMsat = state.amountSats * 1_000L,
|
||||
feeMsat = state.feeSats * 1_000L
|
||||
)
|
||||
val ppmSuffix = if (ppm != null) " (${ppm} ppm)" else ""
|
||||
"Routing fee: ${state.feeSats} sat${if (state.feeSats == 1L) "" else "s"}$ppmSuffix"
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = feeText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(
|
||||
onClick = { vm.resetSendState(); input = "" },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text("Send Another") }
|
||||
}
|
||||
|
||||
is SendState.Error -> {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = state.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = { if (input.isNotBlank()) vm.scan(input) },
|
||||
enabled = input.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text("Try Again") }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = { vm.resetSendState(); input = "" },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text("Clear") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.bitcointxoko.gudariwallet.ui
|
||||
|
||||
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
|
||||
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
|
||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||
import com.bitcointxoko.gudariwallet.api.RouteHintHop
|
||||
import com.bitcointxoko.gudariwallet.util.RouteHintRisk
|
||||
import com.bitcointxoko.gudariwallet.util.SendInputType
|
||||
|
||||
sealed class UiState<out T> {
|
||||
data object Loading : UiState<Nothing>()
|
||||
data class Success<T>(val data: T) : UiState<T>()
|
||||
data class Error(val message: String) : UiState<Nothing>()
|
||||
}
|
||||
|
||||
sealed class ReceiveState {
|
||||
data object Idle : ReceiveState()
|
||||
data object AwaitingInvoice : ReceiveState()
|
||||
data class InvoiceReady(
|
||||
val bolt11 : String,
|
||||
val paymentHash : String,
|
||||
val amountSats : Long,
|
||||
val memo : String,
|
||||
val expiresAt : Long
|
||||
) : ReceiveState()
|
||||
data class PaymentReceived(
|
||||
val amountSats: Long,
|
||||
val memo : String?
|
||||
) : ReceiveState()
|
||||
data class Error(val message: String) : ReceiveState()
|
||||
data class LnurlWithdrawReady(
|
||||
val k1 : String,
|
||||
val callback : String,
|
||||
val defaultDescription : String,
|
||||
val minSats : Long,
|
||||
val maxSats : Long,
|
||||
val domain : String,
|
||||
val lnurl : String // original encoded string, for display
|
||||
) : ReceiveState()
|
||||
}
|
||||
|
||||
|
||||
|
||||
sealed class SendState {
|
||||
data object Idle : SendState()
|
||||
data object Scanning : SendState()
|
||||
data class Bolt11Decoded(
|
||||
override val bolt11 : String,
|
||||
override val amountSats : Long,
|
||||
override val memo : String,
|
||||
override val payee : String? = null,
|
||||
override val nodeAlias : String? = null,
|
||||
override val routeRisk : RouteHintRisk? = null,
|
||||
override val allRouteHints : List<RouteHintHop> = emptyList(),
|
||||
override val routeHintAliases : Map<String, String> = emptyMap()
|
||||
) : SendState(), DecodedInvoice
|
||||
data class LnurlReady(
|
||||
val callback : String,
|
||||
val description : String,
|
||||
val domain : String,
|
||||
val minSats : Long,
|
||||
val maxSats : Long,
|
||||
val commentAllowed : Int,
|
||||
val lnurl : String,
|
||||
val rawRes : LnurlScanResponse
|
||||
) : SendState()
|
||||
data class LnurlInvoiceReady(
|
||||
override val bolt11 : String,
|
||||
override val amountSats : Long,
|
||||
override val memo : String,
|
||||
val domain : String,
|
||||
val lnurl : String,
|
||||
val rawRes : LnurlScanResponse,
|
||||
val amountMsat : Long,
|
||||
val comment : String?,
|
||||
override val payee : String? = null,
|
||||
override val nodeAlias : String? = null,
|
||||
override val routeRisk : RouteHintRisk? = null,
|
||||
override val allRouteHints : List<RouteHintHop> = emptyList(),
|
||||
override val routeHintAliases : Map<String, String> = emptyMap()
|
||||
) : SendState(), DecodedInvoice
|
||||
data object Paying : SendState()
|
||||
data class PaymentSent(
|
||||
val amountSats : Long,
|
||||
val feeSats : Long? = null
|
||||
) : SendState()
|
||||
data class Error(val message: String) : SendState()
|
||||
}
|
||||
|
||||
// ── History list states ────────────────────────────────────────────────────────
|
||||
sealed interface HistoryState {
|
||||
data object Loading : HistoryState
|
||||
data class Success(
|
||||
val payments : List<PaymentRecord>,
|
||||
val canLoadMore : Boolean,
|
||||
val isRefreshing: Boolean = false
|
||||
) : HistoryState
|
||||
data class Error(val message: String) : HistoryState
|
||||
}
|
||||
|
||||
// ── Payment detail states ──────────────────────────────────────────────────────
|
||||
sealed interface DetailState {
|
||||
data object Idle : DetailState
|
||||
data object Loading : DetailState
|
||||
data class Partial(val record: PaymentRecord) : DetailState
|
||||
data class Success(val detail: PaymentDetailResponse) : DetailState
|
||||
data class Error(val message: String) : DetailState
|
||||
}
|
||||
|
||||
// ── Balance states ─────────────────────────────────────────────────────────────
|
||||
sealed interface BalanceState {
|
||||
data object Loading : BalanceState
|
||||
data class Success(
|
||||
val sats : Long,
|
||||
val isRefreshing: Boolean = false,
|
||||
val lastUpdated : Long = System.currentTimeMillis(),
|
||||
val fiatAmount : Double? = null,
|
||||
val fiatCurrency : String? = null
|
||||
) : BalanceState
|
||||
data class Error(
|
||||
val message : String,
|
||||
val staleSats: Long? = null
|
||||
) : BalanceState
|
||||
}
|
||||
|
||||
// ── Clipboard offer states ─────────────────────────────────────────────────────
|
||||
|
||||
sealed interface ClipboardOfferState {
|
||||
data object None : ClipboardOfferState
|
||||
data class Detected(
|
||||
val raw : String,
|
||||
val inputType: SendInputType
|
||||
) : ClipboardOfferState
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared interface for send states that represent a fully decoded invoice
|
||||
* ready for user confirmation. Allows ConfirmCardContent to accept either
|
||||
* Bolt11Decoded or LnurlInvoiceReady without knowing which it is.
|
||||
*/
|
||||
interface DecodedInvoice {
|
||||
val amountSats : Long
|
||||
val memo : String
|
||||
val payee : String?
|
||||
val nodeAlias : String?
|
||||
val bolt11 : String
|
||||
val routeRisk : RouteHintRisk?
|
||||
val allRouteHints : List<RouteHintHop>
|
||||
val routeHintAliases : Map<String, String>
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package com.bitcointxoko.gudariwallet.ui
|
||||
|
||||
import android.content.ClipboardManager
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentPaste
|
||||
import androidx.compose.material.icons.filled.QrCode
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.bitcointxoko.gudariwallet.MainActivity
|
||||
import com.bitcointxoko.gudariwallet.util.SendInputType
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
// ── Tab destinations ──────────────────────────────────────────────────────────
|
||||
|
||||
sealed class TabItem(val route: String, val label: String, val icon: ImageVector) {
|
||||
data object Receive : TabItem("receive", "Receive", Icons.Filled.QrCode)
|
||||
data object Send : TabItem("send", "Send", Icons.AutoMirrored.Filled.Send)
|
||||
}
|
||||
|
||||
private const val ROUTE_HOME = "home"
|
||||
private const val ROUTE_SCANNER = "scanner"
|
||||
private const val ROUTE_HISTORY = "history"
|
||||
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{paymentHash}"
|
||||
|
||||
@Composable
|
||||
fun WalletScreen(
|
||||
vm : WalletViewModel,
|
||||
pendingPaymentHash: MutableState<String?>,
|
||||
pendingPaymentUri : MutableState<String?> // SESSION 09: deep link / intent URI
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
val historyFactory = remember {
|
||||
HistoryViewModelFactory(
|
||||
repo = vm.repo,
|
||||
aliasRepo = vm.aliasRepo,
|
||||
paymentCache = vm.paymentCache,
|
||||
fiatCurrency = vm.selectedCurrency,
|
||||
fiatSatsPerUnit = vm.fiatSatsPerUnit
|
||||
)
|
||||
}
|
||||
val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL
|
||||
&& currentRoute != ROUTE_SCANNER
|
||||
&& currentRoute != ROUTE_HISTORY
|
||||
|
||||
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|
||||
|| currentRoute == ROUTE_PAYMENT_DETAIL
|
||||
|| currentRoute == ROUTE_SCANNER
|
||||
|
||||
// ── Notification tap ──────────────────────────────────────────────────────
|
||||
val hash = pendingPaymentHash.value
|
||||
LaunchedEffect(hash) {
|
||||
if (hash != null) {
|
||||
navController.navigate("payment_detail/$hash") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
pendingPaymentHash.value = null
|
||||
}
|
||||
}
|
||||
|
||||
val activity = context as MainActivity
|
||||
LaunchedEffect(Unit) {
|
||||
activity.windowFocusEvents.collect {
|
||||
val cm = context.getSystemService(ClipboardManager::class.java)
|
||||
val text = cm?.primaryClip?.getItemAt(0)
|
||||
?.coerceToText(context)?.toString()
|
||||
vm.checkClipboard(text)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
vm.navigateToReceive.collect {
|
||||
Log.d("WalletScreen", "LNURL-WITHDRAW [NAV RECEIVED] switching to Receive tab")
|
||||
navController.navigate(TabItem.Receive.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val intentUri = pendingPaymentUri.value
|
||||
LaunchedEffect(intentUri) {
|
||||
if (intentUri != null) {
|
||||
Log.d("WalletScreen", "INTENT [URI] deep link received: $intentUri")
|
||||
pendingPaymentUri.value = null // consume immediately
|
||||
vm.scan(intentUri)
|
||||
navController.navigate(TabItem.Send.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val clipboardOffer by vm.clipboardOfferState.collectAsStateWithLifecycle()
|
||||
|
||||
// Auto-dismiss banner after 8 seconds
|
||||
LaunchedEffect(clipboardOffer) {
|
||||
if (clipboardOffer is ClipboardOfferState.Detected) {
|
||||
delay(8_000)
|
||||
vm.dismissClipboardOffer()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (showBottomBar) {
|
||||
// ── Floating island nav bar: Receive | [Scan] | Send ──────────
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
tonalElevation = 6.dp,
|
||||
shadowElevation = 6.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Receive
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == TabItem.Receive.route,
|
||||
icon = { Icon(TabItem.Receive.icon, contentDescription = TabItem.Receive.label) },
|
||||
label = { Text(TabItem.Receive.label) },
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {
|
||||
if (currentRoute != TabItem.Receive.route) {
|
||||
navController.navigate(TabItem.Receive.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Scan — centre, elevated, not a nav destination
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
FilledIconButton(
|
||||
onClick = { navController.navigate(ROUTE_SCANNER) },
|
||||
modifier = Modifier.size(52.dp),
|
||||
shape = CircleShape
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.QrCodeScanner,
|
||||
contentDescription = "Scan",
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Send
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == TabItem.Send.route,
|
||||
icon = { Icon(TabItem.Send.icon, contentDescription = TabItem.Send.label) },
|
||||
label = { Text(TabItem.Send.label) },
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {
|
||||
if (currentRoute != TabItem.Send.route) {
|
||||
navController.navigate(TabItem.Send.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = ROUTE_HOME,
|
||||
modifier = if (isFullScreenDestination)
|
||||
Modifier.fillMaxSize()
|
||||
else
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
) {
|
||||
composable(ROUTE_HOME) {
|
||||
HomeTab(
|
||||
vm = vm,
|
||||
onHistoryClick = {
|
||||
navController.navigate(ROUTE_HISTORY) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(TabItem.Receive.route) { ReceiveScreen(vm) }
|
||||
composable(TabItem.Send.route) { SendScreen(vm) }
|
||||
|
||||
// Scanner — full screen, no bottom chrome
|
||||
composable(ROUTE_SCANNER) {
|
||||
QrScannerScreen(
|
||||
onScanned = { scanned ->
|
||||
navController.popBackStack()
|
||||
vm.scan(scanned)
|
||||
navController.navigate(TabItem.Send.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onDismiss = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
// History — full screen, no bottom chrome, close → home
|
||||
composable(
|
||||
ROUTE_HISTORY,
|
||||
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||
|
||||
) {
|
||||
HistoryScreen(
|
||||
vm = historyVm,
|
||||
onClose = {
|
||||
navController.popBackStack(ROUTE_HOME, inclusive = false)
|
||||
},
|
||||
onPaymentClick = { payment ->
|
||||
navController.navigate("payment_detail/${payment.paymentHash}")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Payment detail — back → history (natural back stack)
|
||||
composable(
|
||||
route = ROUTE_PAYMENT_DETAIL,
|
||||
arguments = listOf(navArgument("paymentHash") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val paymentHash = backStackEntry.arguments?.getString("paymentHash") ?: ""
|
||||
val historyState by historyVm.state.collectAsState()
|
||||
val payment = (historyState as? HistoryState.Success)
|
||||
?.payments
|
||||
?.firstOrNull { it.paymentHash == paymentHash }
|
||||
|
||||
if (payment != null) {
|
||||
PaymentDetailScreen(
|
||||
payment = payment,
|
||||
vm = historyVm,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
} else {
|
||||
LaunchedEffect(paymentHash) { historyVm.refresh() }
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Clipboard banner — floats over content, slides in from top ────
|
||||
AnimatedVisibility(
|
||||
visible = clipboardOffer is ClipboardOfferState.Detected,
|
||||
enter = slideInVertically(initialOffsetY = { -it }),
|
||||
exit = slideOutVertically(targetOffsetY = { -it }),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.zIndex(1f)
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
) {
|
||||
if (clipboardOffer is ClipboardOfferState.Detected) {
|
||||
val offer = clipboardOffer as ClipboardOfferState.Detected
|
||||
val label = when (offer.inputType) {
|
||||
is SendInputType.Bolt11 -> "Invoice"
|
||||
is SendInputType.Lnurl -> "LNURL"
|
||||
is SendInputType.LightningAddress -> "Lightning Address"
|
||||
else -> "Payment"
|
||||
}
|
||||
val subLabel = when (offer.inputType) {
|
||||
is SendInputType.Lnurl -> "Tap to open"
|
||||
else -> "Tap to pay"
|
||||
}
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
vm.dismissClipboardOffer()
|
||||
vm.scan(offer.raw)
|
||||
navController.navigate(TabItem.Send.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.ContentPaste, contentDescription = null)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
"$label detected in clipboard",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
subLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { vm.dismissClipboardOffer() }) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Dismiss")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
package com.bitcointxoko.gudariwallet.ui
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.bitcointxoko.gudariwallet.api.LNbitsClient
|
||||
import com.bitcointxoko.gudariwallet.data.LNbitsNodeAliasRepository
|
||||
import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
|
||||
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
|
||||
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
||||
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
|
||||
import com.bitcointxoko.gudariwallet.service.NodeAliasService
|
||||
|
||||
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
val secrets = EncryptedSecretStore(app)
|
||||
val baseUrl = secrets.baseUrl.ifBlank { "https://placeholder.invalid" }
|
||||
val api = LNbitsClient.create(baseUrl)
|
||||
val nodeAliasSvc = NodeAliasService(httpClient = LNbitsClient.httpClient, context = app)
|
||||
val aliasRepo = LNbitsNodeAliasRepository(nodeAliasSvc)
|
||||
val repo = LNbitsWalletRepository(api = api, secrets = secrets, context = app)
|
||||
val lnurlCache = LnurlCacheRepository(app)
|
||||
val paymentCache = PaymentCacheRepository(app)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return WalletViewModel(
|
||||
context = app,
|
||||
repo = repo,
|
||||
aliasRepo = aliasRepo,
|
||||
lnurlCache = lnurlCache,
|
||||
paymentCache = paymentCache
|
||||
) as T
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.components
|
||||
|
||||
import android.content.ClipData
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ContentCopy
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
||||
/**
|
||||
* A two-column label/value row used throughout the send flow.
|
||||
*/
|
||||
@Composable
|
||||
fun DetailRow(
|
||||
label : String,
|
||||
value : String,
|
||||
subtitle : String? = null,
|
||||
valueColor : Color = Color.Unspecified,
|
||||
textDecoration : TextDecoration? = null,
|
||||
onValueClick : (() -> Unit)? = null,
|
||||
trailingContent: (@Composable () -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(0.35f)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(0.65f)
|
||||
.padding(start = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f, fill = false)) {
|
||||
Text(
|
||||
text = value,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = valueColor,
|
||||
style = LocalTextStyle.current.copy(textDecoration = textDecoration),
|
||||
modifier = if (onValueClick != null) Modifier.clickable(onClick = onValueClick)
|
||||
else Modifier
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
trailingContent?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A small icon button that copies [value] to the clipboard under [label].
|
||||
*/
|
||||
@Composable
|
||||
fun CopyIconButton(
|
||||
label : String,
|
||||
value : String,
|
||||
size : Dp = 32.dp,
|
||||
iconSize : Dp = 16.dp,
|
||||
tint : Color = Color.Unspecified
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, value)))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(size)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ContentCopy,
|
||||
contentDescription = "Copy $label",
|
||||
modifier = Modifier.size(iconSize),
|
||||
tint = if (tint == Color.Unspecified)
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else tint
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun UnitWheelPicker(
|
||||
units : List<String>,
|
||||
selectedIndex : Int,
|
||||
onIndexSelected : (Int) -> Unit,
|
||||
modifier : Modifier = Modifier // caller controls width entirely
|
||||
) {
|
||||
val itemHeightDp = 32.dp
|
||||
val totalHeightDp = itemHeightDp * 3
|
||||
|
||||
val listState = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex)
|
||||
val snapBehavior = rememberSnapFlingBehavior(lazyListState = listState)
|
||||
|
||||
LaunchedEffect(listState.isScrollInProgress) {
|
||||
if (!listState.isScrollInProgress) {
|
||||
val realIndex = listState.firstVisibleItemIndex.coerceIn(0, units.lastIndex)
|
||||
if (realIndex != selectedIndex) onIndexSelected(realIndex)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
flingBehavior = snapBehavior,
|
||||
modifier = modifier.height(totalHeightDp), // width comes from caller
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
item(key = "ghost_top") {
|
||||
Spacer(modifier = Modifier.height(itemHeightDp))
|
||||
}
|
||||
|
||||
items(count = units.size, key = { it }) { index ->
|
||||
val isCentre = index == selectedIndex
|
||||
val alpha = if (isCentre) 1f else 0.55f
|
||||
val fontWeight = if (isCentre) FontWeight.SemiBold else FontWeight.Normal
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(itemHeightDp)
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { this.alpha = alpha },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = units[index],
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = fontWeight,
|
||||
color = if (isCentre) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "ghost_bottom") {
|
||||
Spacer(modifier = Modifier.height(itemHeightDp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.receive
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Displays a sats amount in bold with an optional fiat subtitle.
|
||||
* Used in MemoStepContent, ReceiveInvoiceContent, and ReceiveSuccessContent.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AmountDisplay(
|
||||
amountSats : Long,
|
||||
fiatLabel : String?,
|
||||
style : TextStyle = MaterialTheme.typography.titleLarge
|
||||
) {
|
||||
Text(
|
||||
text = "$amountSats sats",
|
||||
style = style,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
if (fiatLabel != null) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = fiatLabel,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.receive
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BrightnessHigh
|
||||
import androidx.compose.material.icons.filled.BrightnessLow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
internal fun BrightnessToggleButton(
|
||||
isOn : Boolean,
|
||||
onToggle: () -> Unit
|
||||
) {
|
||||
IconButton(onClick = onToggle) {
|
||||
Icon(
|
||||
imageVector = if (isOn) Icons.Filled.BrightnessHigh
|
||||
else Icons.Filled.BrightnessLow,
|
||||
contentDescription = if (isOn) "Reduce brightness"
|
||||
else "Boost brightness",
|
||||
tint = if (isOn) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.receive
|
||||
|
||||
import android.content.ClipData
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitcointxoko.gudariwallet.util.IntentUtils
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import kotlinx.coroutines.launch
|
||||
import android.graphics.Color
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.set
|
||||
|
||||
|
||||
/**
|
||||
* QR image followed by Copy / Share / Brightness action row.
|
||||
*
|
||||
* @param content What to encode into QR
|
||||
* @param contentDescription Accessibility label for the image.
|
||||
* @param clipLabel Label used when writing to the clipboard (e.g. "Lightning Address").
|
||||
* @param shareTitle Chooser title for the share intent.
|
||||
* @param textToCopy The raw string placed on the clipboard and shared.
|
||||
* @param brightnessOn Current brightness-boost state.
|
||||
* @param onToggleBrightness Called when the brightness button is tapped.
|
||||
*/
|
||||
@Composable
|
||||
internal fun QrDisplayCard(
|
||||
content : String,
|
||||
contentDescription : String,
|
||||
clipLabel : String,
|
||||
shareTitle : String,
|
||||
textToCopy : String,
|
||||
brightnessOn : Boolean,
|
||||
onToggleBrightness : () -> Unit,
|
||||
modifier : Modifier = Modifier,
|
||||
qrModifier : Modifier = Modifier.size(260.dp)
|
||||
) {
|
||||
val bitmap = remember(content) { generateQrBitmap(content).asImageBitmap() }
|
||||
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = contentDescription,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = qrModifier
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText(clipLabel, textToCopy))
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Filled.ContentCopy, contentDescription = "Copy",
|
||||
modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Copy")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { IntentUtils.shareText(context, textToCopy, shareTitle) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = "Share",
|
||||
modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Share")
|
||||
}
|
||||
|
||||
BrightnessToggleButton(isOn = brightnessOn, onToggle = onToggleBrightness)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val QR_BITMAP_SIZE = 512
|
||||
|
||||
private fun generateQrBitmap(content: String): Bitmap {
|
||||
val bits = QRCodeWriter().encode(
|
||||
content,
|
||||
BarcodeFormat.QR_CODE,
|
||||
QR_BITMAP_SIZE,
|
||||
QR_BITMAP_SIZE
|
||||
)
|
||||
return createBitmap(QR_BITMAP_SIZE, QR_BITMAP_SIZE, Bitmap.Config.RGB_565).apply {
|
||||
for (x in 0 until QR_BITMAP_SIZE)
|
||||
for (y in 0 until QR_BITMAP_SIZE)
|
||||
this[x, y] = if (bits[x, y]) Color.BLACK else Color.WHITE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.send
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.bitcointxoko.gudariwallet.ui.DecodedInvoice
|
||||
import com.bitcointxoko.gudariwallet.ui.SendState
|
||||
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.components.CopyIconButton
|
||||
import com.bitcointxoko.gudariwallet.ui.components.DetailRow
|
||||
import com.bitcointxoko.gudariwallet.util.fiatLabel
|
||||
import com.bitcointxoko.gudariwallet.util.payingToLabel
|
||||
|
||||
@Composable
|
||||
internal fun Bolt11ConfirmCard(
|
||||
state : SendState.Bolt11Decoded,
|
||||
activity : FragmentActivity,
|
||||
vm : WalletViewModel,
|
||||
fiatRate : Double?,
|
||||
fiatCurrency: String?
|
||||
) {
|
||||
ConfirmCardContent(
|
||||
title = "Invoice Details",
|
||||
invoice = state,
|
||||
fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency),
|
||||
onPay = {
|
||||
vm.requestPayment(activity, "Send ${state.amountSats} sats") {
|
||||
vm.payBolt11(state.bolt11)
|
||||
}
|
||||
},
|
||||
onCancel = { vm.resetSendState() }
|
||||
)
|
||||
}
|
||||
@Composable
|
||||
internal fun LnurlInvoiceConfirmCard(
|
||||
state : SendState.LnurlInvoiceReady,
|
||||
activity : FragmentActivity,
|
||||
vm : WalletViewModel,
|
||||
fiatRate : Double?,
|
||||
fiatCurrency: String?
|
||||
) {
|
||||
val label = payingToLabel(state.lnurl, state.domain)
|
||||
ConfirmCardContent(
|
||||
title = "Paying to $label",
|
||||
invoice = state,
|
||||
fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency),
|
||||
onPay = {
|
||||
val label = payingToLabel(state.lnurl, state.domain)
|
||||
vm.requestPayment(activity, "Send ${state.amountSats} sats to $label") {
|
||||
vm.payLnurlInvoice(state)
|
||||
}
|
||||
},
|
||||
onCancel = { vm.resetSendState() }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmCardContent(
|
||||
title : String,
|
||||
invoice : DecodedInvoice,
|
||||
fiatLabel: String?,
|
||||
onPay : () -> Unit,
|
||||
onCancel : () -> Unit
|
||||
) {
|
||||
val amountSats = invoice.amountSats
|
||||
val memo = invoice.memo
|
||||
val nodeAlias = invoice.nodeAlias
|
||||
val bolt11 = invoice.bolt11
|
||||
val payee = invoice.payee
|
||||
val routeRisk = invoice.routeRisk
|
||||
val allRouteHints = invoice.allRouteHints
|
||||
val routeHintAliases = invoice.routeHintAliases
|
||||
val context = LocalContext.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(2.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
DetailRow(
|
||||
label = "Amount",
|
||||
value = "$amountSats sats",
|
||||
subtitle = fiatLabel
|
||||
)
|
||||
|
||||
if (memo.isNotBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
DetailRow("Memo", memo)
|
||||
}
|
||||
|
||||
if (!payee.isNullOrBlank()) {
|
||||
val ambossUrl = "https://amboss.space/node/${payee}"
|
||||
val destinationLabel = nodeAlias
|
||||
?: "${payee.take(8)}…${payee.takeLast(8)}"
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
DetailRow(
|
||||
label = "Destination",
|
||||
value = destinationLabel,
|
||||
valueColor = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
onValueClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
|
||||
},
|
||||
trailingContent = {
|
||||
CopyIconButton("node_id", payee)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
DetailRow(
|
||||
label = "Invoice",
|
||||
value = bolt11.take(20) + "…" + bolt11.takeLast(6),
|
||||
trailingContent = {
|
||||
CopyIconButton("lightning_invoice", bolt11)
|
||||
}
|
||||
)
|
||||
|
||||
if (routeRisk != null) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
RouteHintWarningBanner(
|
||||
risk = routeRisk,
|
||||
allRouteHints = allRouteHints,
|
||||
routeHintAliases = routeHintAliases
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = onPay,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
PayButtonText(amountSats = invoice.amountSats, fiatLabel = fiatLabel)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
PayCancelButton(onCancel = onCancel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PayButtonText(amountSats: Long, fiatLabel: String?) {
|
||||
Text(
|
||||
if (fiatLabel != null) "Pay $amountSats sats · $fiatLabel"
|
||||
else "Pay $amountSats sats"
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PayCancelButton(
|
||||
onCancel : () -> Unit,
|
||||
) {
|
||||
OutlinedButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { Text("Cancel") }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.send
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitcointxoko.gudariwallet.ui.SendState
|
||||
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
|
||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
||||
import com.bitcointxoko.gudariwallet.util.fiatLabel
|
||||
import com.bitcointxoko.gudariwallet.util.formatFiat
|
||||
import com.bitcointxoko.gudariwallet.util.payingToLabel
|
||||
import com.bitcointxoko.gudariwallet.util.satsToFiat
|
||||
|
||||
@Composable
|
||||
internal fun LnurlPayForm(
|
||||
state : SendState.LnurlReady,
|
||||
vm : WalletViewModel,
|
||||
fiatRate : Double?,
|
||||
fiatCurrency: String?
|
||||
) {
|
||||
var amount by remember { mutableStateOf(state.minSats.toString()) }
|
||||
var comment by remember { mutableStateOf("") }
|
||||
val isFixed = state.minSats == state.maxSats
|
||||
|
||||
// Long? — null means the field is empty or non-numeric, not zero.
|
||||
// This is the key hardening: 0L can no longer silently pass through.
|
||||
val sats: Long? = amount.toLongOrNull()
|
||||
|
||||
val isAmountValid = sats != null && sats in state.minSats..state.maxSats
|
||||
val fiatLabel: String? = fiatLabel(sats, fiatRate, fiatCurrency)
|
||||
|
||||
Column {
|
||||
Text("Paying to ${payingToLabel(state.lnurl, state.domain)}", style = MaterialTheme.typography.titleMedium)
|
||||
if (state.description.isNotBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(state.description, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = amount,
|
||||
onValueChange = { if (it.all(Char::isDigit)) amount = it },
|
||||
label = { Text("Amount (sats)") },
|
||||
isError = amount.isNotBlank() && !isAmountValid,
|
||||
supportingText = {
|
||||
when {
|
||||
// Show error when user has typed something invalid
|
||||
amount.isNotBlank() && !isAmountValid -> {
|
||||
Text(
|
||||
text = "Enter an amount between ${state.minSats} and ${state.maxSats} sats",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
// Show fiat range when rate is available
|
||||
fiatRate != null && fiatCurrency != null -> {
|
||||
val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency)
|
||||
val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency)
|
||||
if (isFixed) Text(minFiat)
|
||||
else Text("${state.minSats} – ${state.maxSats} sats · $minFiat – $maxFiat")
|
||||
}
|
||||
// Sats-only range when no rate
|
||||
!isFixed -> Text("${state.minSats} – ${state.maxSats} sats")
|
||||
else -> {}
|
||||
}
|
||||
},
|
||||
enabled = !isFixed,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
if (state.commentAllowed > 0) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = comment,
|
||||
onValueChange = { if (it.length <= state.commentAllowed) comment = it },
|
||||
label = { Text("Comment (optional)") },
|
||||
supportingText = { Text("${comment.length}/${state.commentAllowed}") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
// Double-guard: sats must be non-null and in range before firing
|
||||
if (sats != null && isAmountValid) {
|
||||
vm.fetchLnurlInvoice(
|
||||
rawRes = state.rawRes,
|
||||
lnurl = state.lnurl,
|
||||
domain = state.domain,
|
||||
amountMsat = sats * WalletConstants.MSAT_PER_SAT,
|
||||
comment = comment.takeIf { it.isNotBlank() }
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = isAmountValid,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
if (fiatLabel != null) "Pay $amount sats · $fiatLabel"
|
||||
else "Pay $amount sats"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.send
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import com.bitcointxoko.gudariwallet.api.RouteHintHop
|
||||
import com.bitcointxoko.gudariwallet.ui.components.CopyIconButton
|
||||
import com.bitcointxoko.gudariwallet.util.RiskLevel
|
||||
import com.bitcointxoko.gudariwallet.util.RouteHintRisk
|
||||
import com.bitcointxoko.gudariwallet.util.baseFeeLabel
|
||||
|
||||
@Composable
|
||||
internal fun RouteHintWarningBanner(
|
||||
risk : RouteHintRisk,
|
||||
allRouteHints : List<RouteHintHop>,
|
||||
routeHintAliases: Map<String, String>,
|
||||
) {
|
||||
val isHigh = risk.level == RiskLevel.HIGH
|
||||
val containerCol = if (isHigh) MaterialTheme.colorScheme.errorContainer
|
||||
else MaterialTheme.colorScheme.tertiaryContainer
|
||||
val contentCol = if (isHigh) MaterialTheme.colorScheme.onErrorContainer
|
||||
else MaterialTheme.colorScheme.onTertiaryContainer
|
||||
|
||||
val offendingNode = routeHintAliases[risk.worstHop.publicKey]
|
||||
?: risk.worstHop.publicKey.let { "${it.take(8)}…${it.takeLast(8)}" }
|
||||
|
||||
val estimatedFeeSats = (risk.estimatedFeeMsat + 999L) / 1_000L
|
||||
|
||||
var detailsExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
color = containerCol,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = contentCol,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (isHigh) "High routing fees in invoice"
|
||||
else "Elevated routing fees in invoice",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = contentCol
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "This invoice forces payment through a private channel " +
|
||||
"operated by $offendingNode.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = contentCol
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val feeRateLine = buildString {
|
||||
append("Effective fee rate: ${risk.effectivePpm} ppm")
|
||||
append(" (${risk.worstHop.ppmFee} ppm")
|
||||
if (risk.worstHop.baseFeeMsat > 0) append(" + ${baseFeeLabel(risk.worstHop.baseFeeMsat)} base")
|
||||
append(")")
|
||||
}
|
||||
Text(text = feeRateLine, style = MaterialTheme.typography.bodySmall, color = contentCol)
|
||||
Text(
|
||||
text = "Estimated extra fee on this payment: " +
|
||||
"~$estimatedFeeSats sat${if (estimatedFeeSats == 1L) "" else "s"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = contentCol
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (allRouteHints.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
TextButton(
|
||||
onClick = { detailsExpanded = !detailsExpanded },
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (detailsExpanded) "Hide details ▴" else "See details ▾",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentCol
|
||||
)
|
||||
}
|
||||
if (detailsExpanded) {
|
||||
RouteHintDetailsPanel(
|
||||
hops = allRouteHints,
|
||||
aliases = routeHintAliases,
|
||||
worstKey = risk.worstHop.publicKey,
|
||||
tintColor = contentCol
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RouteHintDetailsPanel(
|
||||
hops : List<RouteHintHop>,
|
||||
aliases : Map<String, String>,
|
||||
worstKey : String,
|
||||
tintColor: Color
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Column(modifier = Modifier.padding(top = 6.dp)) {
|
||||
HorizontalDivider(color = tintColor.copy(alpha = 0.3f))
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "Private route hint hops",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = tintColor.copy(alpha = 0.7f)
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
hops.forEachIndexed { index, hop ->
|
||||
val alias = aliases[hop.publicKey]
|
||||
val truncatedKey = "${hop.publicKey.take(8)}…${hop.publicKey.takeLast(8)}"
|
||||
val displayName = alias ?: truncatedKey
|
||||
val ambossUrl = "https://amboss.space/node/${hop.publicKey}"
|
||||
val isWorst = hop.publicKey == worstKey
|
||||
|
||||
if (index > 0) Spacer(Modifier.height(6.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
fontWeight = if (isWorst) androidx.compose.ui.text.font.FontWeight.Bold
|
||||
else androidx.compose.ui.text.font.FontWeight.Normal
|
||||
),
|
||||
color = tintColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
|
||||
}
|
||||
)
|
||||
CopyIconButton("node_id", hop.publicKey, size = 28.dp, iconSize = 14.dp)
|
||||
Text(
|
||||
text = "${hop.ppmFee} ppm · ${baseFeeLabel(hop.baseFeeMsat)} base",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = tintColor.copy(alpha = 0.85f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 160.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
@@ -0,0 +1,60 @@
|
||||
// ui/theme/SemanticColors.kt
|
||||
|
||||
package com.bitcointxoko.gudariwallet.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@Immutable
|
||||
data class SemanticColors(
|
||||
val success : Color, // text / icon color
|
||||
val successContainer : Color, // chip background
|
||||
val onSuccessContainer: Color, // chip text
|
||||
|
||||
val warning : Color,
|
||||
val warningContainer : Color,
|
||||
val onWarningContainer: Color,
|
||||
|
||||
// Error is already in MaterialTheme.colorScheme — mirrored here for symmetry
|
||||
val error : Color,
|
||||
val errorContainer : Color,
|
||||
val onErrorContainer : Color,
|
||||
)
|
||||
|
||||
val LightSemanticColors = SemanticColors(
|
||||
success = Color(0xFF2E7D32),
|
||||
successContainer = Color(0xFFC8E6C9),
|
||||
onSuccessContainer = Color(0xFF1B5E20),
|
||||
|
||||
warning = Color(0xFF92400E),
|
||||
warningContainer = Color(0xFFFFF3E0),
|
||||
onWarningContainer = Color(0xFF4E2600),
|
||||
|
||||
error = Color(0xFFBA1A1A),
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF410002),
|
||||
)
|
||||
|
||||
val DarkSemanticColors = SemanticColors(
|
||||
success = Color(0xFFA5D6A7),
|
||||
successContainer = Color(0xFF1B5E20),
|
||||
onSuccessContainer = Color(0xFFC8E6C9),
|
||||
|
||||
warning = Color(0xFFFFB74D),
|
||||
warningContainer = Color(0xFF4E2600),
|
||||
onWarningContainer = Color(0xFFFFE0B2),
|
||||
|
||||
error = Color(0xFFFFB4AB),
|
||||
errorContainer = Color(0xFF410002),
|
||||
onErrorContainer = Color(0xFFFFDAD6),
|
||||
)
|
||||
|
||||
val LocalSemanticColors = staticCompositionLocalOf { LightSemanticColors }
|
||||
|
||||
// Convenience accessor — use like: MaterialTheme.semanticColors.success
|
||||
@Composable
|
||||
fun semanticColors(): SemanticColors = LocalSemanticColors.current
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun GudariWalletTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
Reference in New Issue
Block a user