rename example

This commit is contained in:
2026-06-01 03:09:19 +02:00
parent 7f3d14a764
commit b5473bfafc
59 changed files with 261 additions and 182 deletions
@@ -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
)
*/
)