feat: nwc details

This commit is contained in:
2026-06-09 18:17:32 +02:00
parent 7fad5ec9dd
commit 4a2b66dcf2
11 changed files with 1056 additions and 122 deletions
@@ -162,7 +162,7 @@ interface LNbitsApi {
@GET("nwcprovider/api/v1/nwc/{pubkey}")
suspend fun getNwcKey(
@Header("X-Api-Key") apiKey: String,
@Header("X-Api-Key") adminKey: String,
@Path("pubkey") pubkey: String,
@Query("include_expired") includeExpired: Boolean = false,
@Query("refresh_last_used") refreshLastUsed: Boolean = false
@@ -319,7 +319,7 @@ class LNbitsWalletRepository(
): NwcGetResponse {
Timber.d("NWC [KEY ] fetching key pubkey=${pubkey.take(16)}")
val result = api.getNwcKey(
apiKey = secrets.invoiceKey(),
adminKey = secrets.adminKey(),
pubkey = pubkey,
includeExpired = includeExpired,
refreshLastUsed = refreshLastUsed
@@ -352,6 +352,53 @@ val EnHomeStrings = AppStrings(
scheduleSync = "Schedule sync",
schedule = "Schedule",
cancel = "Cancel"
),
nwc = NwcStrings(
back = "Back",
next = "Next",
done = "Done",
cancel = "Cancel",
schedule = "Schedule",
// ── Connection detail ─────────────────────────────────────────────────────
connectionDetailTitle = "Connection",
connectionUnknownWallet = "Unknown app",
connectionSectionDetails = "Details",
connectionRowCreated = "Connected",
connectionRowLastUsed = "Last used",
connectionRowExpires = "Expires",
connectionRowPubkey = "Public key",
connectionNeverUsed = "Never",
connectionNoExpiry = "Never",
connectionExpired = { date -> "Expired · $date" },
connectionSectionPermissions = "Permissions",
connectionSectionBudgets = "Spending budgets",
connectionBudgetTitle = { window -> "$window budget" },
connectionBudgetUsed = { sats -> "Used: $sats sats" },
connectionBudgetTotal = { sats -> "of $sats sats" },
connectionRevoke = "Revoke connection",
connectionStatusActive = "Active",
connectionStatusExpired = "Expired",
// ── NWC permissions ───────────────────────────────────────────────────────
permPayInvoice = "Pay invoices",
permGetBalance = "Read balance",
permGetInfo = "Read wallet info",
permMakeInvoice = "Create invoices",
permLookupInvoice = "Look up invoices",
permListTransactions = "View transaction history",
permSignMessage = "Sign messages",
permPay = "Send payments",
permInvoice = "Create invoices",
permLookupHistory = "Lookup status & transaction history",
permBalanceInfo = "Read balance & account info",
permSendPayments = "Send payments",
permCreateInvoices = "Create invoices",
permLookupInvoiceStatus = "Lookup status of invoice",
permReadTransactionHistory = "Read transaction history",
permReadWalletBalance = "Read wallet balance",
permReadAccountInfo = "Read account info",
)
)
@@ -1,6 +1,7 @@
package com.bitcointxoko.gudariwallet.i18n
import cafe.adriel.lyricist.LyricistStrings
import com.bitcointxoko.gudariwallet.i18n.NwcStrings
@LyricistStrings(languageTag = "es")
val EsHomeStrings = AppStrings(
@@ -352,5 +353,53 @@ val EsHomeStrings = AppStrings(
scheduleSync = "Programar sincronización",
schedule = "Programar",
cancel = "Cancelar"
)
),
nwc = NwcStrings(
// ── Shared ────────────────────────────────────────────────────────────────
back = "Atrás",
next = "Siguiente",
done = "Listo",
cancel = "Cancelar",
schedule = "Programar",
// ── Connection detail ─────────────────────────────────────────────────────
connectionDetailTitle = "Conexión",
connectionUnknownWallet = "App desconocida",
connectionSectionDetails = "Detalles",
connectionRowCreated = "Conectado",
connectionRowLastUsed = "Último uso",
connectionRowExpires = "Vence",
connectionRowPubkey = "Clave pública",
connectionNeverUsed = "Nunca",
connectionNoExpiry = "Sin vencimiento",
connectionExpired = { date -> "Vencido · $date" },
connectionSectionPermissions = "Permisos",
connectionSectionBudgets = "Presupuestos de gasto",
connectionBudgetTitle = { window -> "Presupuesto $window" },
connectionBudgetUsed = { sats -> "Usado: $sats sats" },
connectionBudgetTotal = { sats -> "de $sats sats" },
connectionRevoke = "Revocar conexión",
connectionStatusActive = "Activa",
connectionStatusExpired = "Vencida",
// ── NWC permissions ───────────────────────────────────────────────────────
permPayInvoice = "Pagar facturas",
permGetBalance = "Ver saldo",
permGetInfo = "Ver información del monedero",
permMakeInvoice = "Crear facturas",
permLookupInvoice = "Consultar facturas",
permListTransactions = "Ver historial de transacciones",
permSignMessage = "Firmar mensajes",
permPay = "Enviar pagos",
permInvoice = "Crear facturas",
permLookupHistory = "Consultar estado e historial de transacciones",
permBalanceInfo = "Ver saldo e información de cuenta",
permSendPayments = "Enviar pagos",
permCreateInvoices = "Crear facturas",
permLookupInvoiceStatus = "Consultar estado de factura",
permReadTransactionHistory = "Leer historial de transacciones",
permReadWalletBalance = "Leer saldo del monedero",
permReadAccountInfo = "Leer información de cuenta",
)
)
@@ -352,5 +352,52 @@ val EuStrings = AppStrings(
scheduleSync = "Programatu sinkronizazioa",
schedule = "Programatu",
cancel = "Utzi"
),
nwc = NwcStrings(
// ── Shared ────────────────────────────────────────────────────────────────
back = "Atzera",
next = "Hurrengoa",
done = "Eginda",
cancel = "Utzi",
schedule = "Programatu",
// ── Connection detail ─────────────────────────────────────────────────────
connectionDetailTitle = "Konexioa",
connectionUnknownWallet = "App ezezaguna",
connectionSectionDetails = "Xehetasunak",
connectionRowCreated = "Konektatuta",
connectionRowLastUsed = "Azken erabilera",
connectionRowExpires = "Iraungitze-data",
connectionRowPubkey = "Gako publikoa",
connectionNeverUsed = "Inoiz ez",
connectionNoExpiry = "Iraungitze-datarik ez",
connectionExpired = { date -> "Iraungita · $date" },
connectionSectionPermissions = "Baimenak",
connectionSectionBudgets = "Gastu-aurrekontuak",
connectionBudgetTitle = { window -> "$window aurrekontua" },
connectionBudgetUsed = { sats -> "Erabilita: $sats sat" },
connectionBudgetTotal = { sats -> "$sats satetatik" },
connectionRevoke = "Konexioa baliogabetu",
connectionStatusActive = "Aktibo",
connectionStatusExpired = "Iraungita",
// ── NWC permissions ───────────────────────────────────────────────────────
permPayInvoice = "Fakturak ordaindu",
permGetBalance = "Saldoa ikusi",
permGetInfo = "Zorroko informazioa ikusi",
permMakeInvoice = "Fakturak sortu",
permLookupInvoice = "Fakturak kontsultatu",
permListTransactions = "Transakzio-historia ikusi",
permSignMessage = "Mezuak sinatu",
permPay = "Ordainketak bidali",
permInvoice = "Fakturak sortu",
permLookupHistory = "Egoera eta transakzio-historia kontsultatu",
permBalanceInfo = "Saldoa eta kontuaren informazioa ikusi",
permSendPayments = "Ordainketak bidali",
permCreateInvoices = "Fakturak sortu",
permLookupInvoiceStatus = "Fakturaren egoera kontsultatu",
permReadTransactionHistory = "Transakzio-historia irakurri",
permReadWalletBalance = "Zorroko saldoa irakurri",
permReadAccountInfo = "Kontuaren informazioa irakurri",
)
)
@@ -304,7 +304,8 @@ data class AppStrings(
val paymentRowPending : String,
val paymentRowFailed : String,
val onboarding: OnboardingStrings
val onboarding: OnboardingStrings,
val nwc: NwcStrings
)
data class OnboardingStrings(
@@ -351,4 +352,57 @@ data class OnboardingStrings(
val scheduleSync: String,
val schedule: String,
val cancel: String
)
data class NwcStrings(
// ── Shared / navigation ───────────────────────────────────────────────────
val back : String,
val next : String,
val done : String,
val cancel : String,
val schedule : String,
// ── Connection detail ─────────────────────────────────────────────────────
val connectionDetailTitle : String,
val connectionUnknownWallet : String,
val connectionSectionDetails : String,
val connectionRowCreated : String,
val connectionRowLastUsed : String,
val connectionRowExpires : String,
val connectionRowPubkey : String,
val connectionNeverUsed : String,
val connectionNoExpiry : String,
/** "Expired · {date}" */
val connectionExpired : (date: String) -> String,
val connectionSectionPermissions : String,
val connectionSectionBudgets : String,
/** "{window} budget" e.g. "Daily budget" */
val connectionBudgetTitle : (window: String) -> String,
/** "Used: {sats} sats" */
val connectionBudgetUsed : (sats: Long) -> String,
/** "of {sats} sats" */
val connectionBudgetTotal : (sats: Long) -> String,
val connectionRevoke : String,
val connectionStatusActive : String,
val connectionStatusExpired : String,
// ── NWC permission labels ─────────────────────────────────────────────────
val permPayInvoice : String,
val permGetBalance : String,
val permGetInfo : String,
val permMakeInvoice : String,
val permLookupInvoice : String,
val permListTransactions : String,
val permSignMessage : String,
val permPay : String, // "Send payments"
val permInvoice : String, // "Create invoices"
val permLookupHistory: String, // "Lookup status & transaction history"
val permBalanceInfo : String, // "Read balance & account info"
val permSendPayments: String,
val permCreateInvoices: String,
val permLookupInvoiceStatus: String,
val permReadTransactionHistory: String,
val permReadWalletBalance: String,
val permReadAccountInfo: String,
)
@@ -62,6 +62,8 @@ import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.ui.nwc.ConnectionDetailScreen
import com.bitcointxoko.gudariwallet.ui.nwc.ConnectionDetailState
import com.bitcointxoko.gudariwallet.ui.nwc.NwcScreen
import com.bitcointxoko.gudariwallet.ui.nwc.NwcViewModel
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
@@ -84,6 +86,7 @@ private const val ROUTE_SCANNER = "scanner"
private const val ROUTE_HISTORY = "history"
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
private const val ROUTE_NWC = "nwc"
private const val ROUTE_CONNECTION_DETAIL = "connection_detail/{pubkey}"
@Composable
fun WalletScreen(
@@ -134,11 +137,13 @@ fun WalletScreen(
&& currentRoute != ROUTE_SCANNER
&& currentRoute != ROUTE_HISTORY
&& currentRoute != ROUTE_NWC
&& currentRoute != ROUTE_CONNECTION_DETAIL
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|| currentRoute == ROUTE_PAYMENT_DETAIL
|| currentRoute == ROUTE_SCANNER
|| currentRoute == ROUTE_NWC
|| currentRoute == ROUTE_CONNECTION_DETAIL
// ── Notification tap ──────────────────────────────────────────────────────
val hash = pendingPaymentHash.value
@@ -437,9 +442,61 @@ fun WalletScreen(
viewModel = nwcVm,
onBack = {
navController.popBackStack(ROUTE_HOME, inclusive = false)
},
onConnectionClick = { pubkey ->
navController.navigate("connection_detail/$pubkey") {
launchSingleTop = true
}
}
)
}
composable(
route = ROUTE_CONNECTION_DETAIL,
arguments = listOf(navArgument("pubkey") { type = NavType.StringType }),
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) { backStackEntry ->
val pubkey = backStackEntry.arguments?.getString("pubkey") ?: ""
val connectionState by nwcVm.connectionDetailState.collectAsStateWithLifecycle()
LaunchedEffect(pubkey) { nwcVm.loadConnectionDetail(pubkey) }
DisposableEffect(Unit) { onDispose { nwcVm.clearConnectionDetail() } }
when (val state = connectionState) {
is ConnectionDetailState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) { CircularProgressIndicator() }
}
is ConnectionDetailState.Error -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = state.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
}
is ConnectionDetailState.Success -> {
ConnectionDetailScreen(
connection = state.connection,
budgets = state.budgets,
onBack = { navController.popBackStack() },
onRevoke = {
nwcVm.revokeConnection(pubkey)
navController.popBackStack()
}
)
}
}
}
}
// ── Clipboard banner — floats over content, slides in from top ────
@@ -0,0 +1,522 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import android.content.ClipData
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
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.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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.AccountBalanceWallet
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Draw
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
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.Scaffold
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.i18n.AppStrings
import com.bitcointxoko.gudariwallet.ui.history.DetailRow
import com.bitcointxoko.gudariwallet.ui.history.DetailSection
import kotlinx.coroutines.launch
// ── ConnectionDetailScreen.kt ─────────────────────────────────────────────────
//
// Displays a Nostr Wallet Connect (NWC) connection and its associated budgets.
// Modelled on PaymentDetailScreen; reuses DetailSection / DetailRow / StatusChip
// from the same UI kit.
//
// bitcoin.design guidelines applied:
// • Hero card surfaces the most important identity info (wallet + description)
// with a clear active/expired status chip — users know at a glance whether
// the connection is live.
// • Permissions are shown as individual readable rows, not a raw comma string.
// • Budget progress uses a LinearProgressIndicator so spending headroom is
// immediately scannable without arithmetic.
// • Timestamps are human-readable; raw pubkeys are truncated + copyable.
// • Destructive "Revoke" action is placed at the bottom, separated from
// informational content, and uses errorContainer colours.
// ─────────────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConnectionDetailScreen(
connection : ConnectionData, // data { pubkey, wallet, description, … }
budgets : List<BudgetData>, // budgets [ { id, pubkey, budget_msats, … } ]
onBack : () -> Unit,
onRevoke : (() -> Unit)? = null // optional show Revoke button when provided
) {
val strings = LocalAppStrings.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = { Text(strings.nwc.connectionDetailTitle) }, // ← "Connection"
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = strings.nwc.back // ← "Back"
)
}
}
)
}
) { padding ->
ConnectionDetailContent(
connection = connection,
budgets = budgets,
onRevoke = onRevoke,
modifier = Modifier.padding(padding)
)
}
}
// ── Detail content ────────────────────────────────────────────────────────────
@Composable
private fun ConnectionDetailContent(
connection : ConnectionData,
budgets : List<BudgetData>,
onRevoke : (() -> Unit)?,
modifier : Modifier = Modifier
) {
val strings = LocalAppStrings.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val nowSeconds = remember { System.currentTimeMillis() / 1_000L }
val isExpired = connection.expiresAt > 0 && connection.expiresAt < nowSeconds
val permissions = connection.permissions
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// ── Hero card ─────────────────────────────────────────────────────────
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// App / wallet icon placeholder
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(56.dp)
) {
Icon(
imageVector = Icons.Default.Link,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier
.padding(14.dp)
.fillMaxSize()
)
}
Spacer(Modifier.height(12.dp))
// Wallet name
Text(
text = connection.description.ifBlank { strings.nwc.connectionUnknownWallet }, // ← "Unknown app"
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(12.dp))
// Active / Expired chip
ConnectionStatusChip(isExpired = isExpired)
}
}
}
// ── Connection details ────────────────────────────────────────────────
item {
DetailSection(title = strings.nwc.connectionSectionDetails) { // ← "Details"
DetailRow(
label = strings.nwc.connectionRowCreated, // ← "Connected"
value = formatEpoch(connection.createdAt)
)
DetailRow(
label = strings.nwc.connectionRowLastUsed, // ← "Last used"
value = if (connection.lastUsed > 0)
formatEpoch(connection.lastUsed)
else
strings.nwc.connectionNeverUsed // ← "Never"
)
DetailRow(
label = strings.nwc.connectionRowExpires, // ← "Expires"
value = when {
connection.expiresAt <= 0 -> strings.nwc.connectionNoExpiry // ← "Never"
isExpired -> strings.nwc.connectionExpired( // ← "Expired · 12 Jan 2025"
formatEpoch(connection.expiresAt)
)
else -> formatEpoch(connection.expiresAt)
},
// valueColor = if (isExpired) MaterialTheme.colorScheme.error else null
)
// Pubkey truncated display, full value copyable
DetailRow(
label = strings.nwc.connectionRowPubkey, // ← "Public key"
value = "${connection.pubkey.take(8)}${connection.pubkey.takeLast(8)}",
monospace = true,
copyable = true,
onCopy = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("pubkey", connection.pubkey))
)
}
}
)
}
}
// ── Permissions ───────────────────────────────────────────────────────
if (permissions.isNotEmpty()) {
item {
DetailSection(title = strings.nwc.connectionSectionPermissions) {
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
permissions.forEachIndexed { index, permission ->
PermissionRow(permission = permission, strings)
if (index < permissions.lastIndex) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 0.5.dp
)
}
}
}
}
}
}
// ── Budgets ───────────────────────────────────────────────────────────
if (budgets.isNotEmpty()) {
item {
Text(
text = strings.nwc.connectionSectionBudgets, // ← "Spending budgets"
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp)
)
}
items(budgets, key = { it.id }) { budget ->
BudgetCard(budget = budget)
}
}
// ── Revoke ────────────────────────────────────────────────────────────
if (onRevoke != null) {
item {
Spacer(Modifier.height(4.dp))
OutlinedButton(
onClick = onRevoke,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.LinkOff,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text(strings.nwc.connectionRevoke) // ← "Revoke connection"
}
}
}
}
}
// ── Budget card ───────────────────────────────────────────────────────────────
@Composable
private fun BudgetCard(budget: BudgetData) {
val strings = LocalAppStrings.current
val totalSats = budget.budgetMsats / 1_000L
val usedSats = budget.usedBudgetMsats / 1_000L
val progress = if (budget.budgetMsats > 0)
(budget.usedBudgetMsats.toFloat() / budget.budgetMsats.toFloat()).coerceIn(0f, 1f)
else 0f
val isOverBudget = budget.usedBudgetMsats > budget.budgetMsats
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Refresh window label e.g. "Daily budget"
Text(
text = strings.nwc.connectionBudgetTitle( // ← "Daily budget"
formatRefreshWindow(budget.refreshWindow)
),
style = MaterialTheme.typography.titleSmall
)
// Progress bar
LinearProgressIndicator(
progress = { progress },
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp)),
color = if (isOverBudget)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
// Used / Total
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = strings.nwc.connectionBudgetUsed(usedSats), // ← "Used: 1,200 sats"
style = MaterialTheme.typography.bodySmall,
color = if (isOverBudget)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = strings.nwc.connectionBudgetTotal(totalSats), // ← "of 10,000 sats"
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Created at
DetailRow(
label = strings.nwc.connectionRowCreated, // ← "Created"
value = formatEpoch(budget.createdAt)
)
}
}
}
// ── Permission row ────────────────────────────────────────────────────────────
@Composable
private fun PermissionRow(permission: ConnectionPermission, strings: AppStrings) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = permission.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier
.padding(7.dp)
.fillMaxSize()
)
}
Text(
text = permission.label(strings), // already localised
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
}
}
@Composable
private fun PermissionChip(permission: String) {
val strings = LocalAppStrings.current
SuggestionChip(
onClick = {}, // read-only; no action
label = {
Text(
text = formatPermissionLabel(permission, strings),
style = MaterialTheme.typography.labelMedium
)
},
icon = {
Icon(
imageVector = permissionIcon(permission),
contentDescription = null,
modifier = Modifier.size(SuggestionChipDefaults.IconSize)
)
}
)
}
// ── Status chip ───────────────────────────────────────────────────────────────
@Composable
private fun ConnectionStatusChip(isExpired: Boolean) {
val strings = LocalAppStrings.current
Surface(
shape = RoundedCornerShape(50),
color = if (isExpired)
MaterialTheme.colorScheme.errorContainer
else
MaterialTheme.colorScheme.primaryContainer
) {
Text(
text = if (isExpired) strings.nwc.connectionStatusExpired // ← "Expired"
else strings.nwc.connectionStatusActive, // ← "Active"
style = MaterialTheme.typography.labelSmall,
color = if (isExpired)
MaterialTheme.colorScheme.onErrorContainer
else
MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Converts a refresh_window value (seconds) to a human-readable label.
* Common NWC values: 86400 = daily, 604800 = weekly, 2592000 = monthly.
*/
private fun formatRefreshWindow(seconds: Long): String = when (seconds) {
0L -> "One-time"
86_400L -> "Daily"
604_800L -> "Weekly"
2_592_000L -> "Monthly"
else -> "${seconds / 86_400} days"
}
/**
* Maps a raw NWC method string to a friendly label.
* Extend as your permission set grows.
*/
@Composable
private fun formatPermissionLabel(permission: String, strings: AppStrings): String =
when (permission.trim().lowercase()) {
"pay_invoice" -> strings.nwc.permPayInvoice // ← "Pay invoices"
"get_balance" -> strings.nwc.permGetBalance // ← "Read balance"
"get_info" -> strings.nwc.permGetInfo // ← "Read wallet info"
"make_invoice" -> strings.nwc.permMakeInvoice // ← "Create invoices"
"lookup_invoice" -> strings.nwc.permLookupInvoice // ← "Look up invoices"
"list_transactions" -> strings.nwc.permListTransactions // ← "View transaction history"
"sign_message" -> strings.nwc.permSignMessage // ← "Sign messages"
else -> permission // fallback: raw string
}
/** Maps a permission string to a representative icon. */
private fun permissionIcon(permission: String): ImageVector =
when (permission.trim().lowercase()) {
"pay_invoice" -> Icons.Default.ArrowUpward
"get_balance" -> Icons.Default.AccountBalanceWallet
"get_info" -> Icons.Default.Info
"make_invoice" -> Icons.Default.ArrowDownward
"lookup_invoice" -> Icons.Default.Search
"list_transactions" -> Icons.AutoMirrored.Filled.List
"sign_message" -> Icons.Default.Draw
else -> Icons.Default.Key
}
// ── Data models (if not already defined elsewhere) ───────────────────────────
data class ConnectionData(
val pubkey : String,
val wallet : String,
val description : String,
val expiresAt : Long, // Unix seconds; 0 = no expiry
val permissions : List<ConnectionPermission>,
val createdAt : Long, // Unix seconds
val lastUsed : Long // Unix seconds; 0 = never
)
/**
* A resolved permission — produced by matching NwcKey.permissions against
* ALL_PERMISSIONS and carrying only what the detail screen needs.
*/
data class ConnectionPermission(
val serverKey : String, // "pay", "invoice", "lookup", etc.
val icon : ImageVector
)
internal fun ConnectionPermission.label(strings: AppStrings): String =
when (serverKey) {
"pay" -> strings.nwc.permSendPayments // "Send payments"
"invoice" -> strings.nwc.permCreateInvoices // "Create invoices"
"lookup" -> strings.nwc.permLookupInvoiceStatus // "Lookup status of invoice"
"history" -> strings.nwc.permReadTransactionHistory // "Read transaction history"
"balance" -> strings.nwc.permReadWalletBalance // "Read wallet balance"
"info" -> strings.nwc.permReadAccountInfo // "Read account info"
else -> serverKey
}
data class BudgetData(
val id : Long,
val pubkey : String,
val budgetMsats : Long,
val refreshWindow : Long, // seconds
val createdAt : Long,
val usedBudgetMsats : Long
)
@@ -0,0 +1,73 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBalanceWallet
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Search
import androidx.compose.ui.graphics.vector.ImageVector
import com.bitcointxoko.gudariwallet.api.NwcBudget
import com.bitcointxoko.gudariwallet.api.NwcKey
import com.bitcointxoko.gudariwallet.i18n.AppStrings
/**
* Maps the API model [NwcKey] to the UI domain model [ConnectionData].
* Keeps raw API types out of the Composable layer.
*/
internal fun NwcKey.toConnectionData() = ConnectionData(
pubkey = pubkey,
wallet = wallet,
description = description,
expiresAt = expires_at,
permissions = parsePermissions(permissions),
createdAt = created_at,
lastUsed = last_used
)
/**
* Splits the raw space-separated permissions string and matches each word
* against ALL_PERMISSIONS by serverKey, preserving ALL_PERMISSIONS order.
*
* "pay invoice lookup history balance info"
* → words = {"pay", "invoice", "lookup", "history", "balance", "info"}
* → filter ALL_PERMISSIONS where serverKey ∈ words
* → map to ConnectionPermission with localised label + icon
*/
private fun parsePermissions(raw: String): List<ConnectionPermission> {
val words = raw.trim().split("\\s+".toRegex()).toSet()
return ALL_PERMISSIONS
.filter { it.serverKey in words }
.map { perm ->
ConnectionPermission(
serverKey = perm.serverKey,
icon = permissionIcon(perm.serverKey)
)
}
}
private fun permissionIcon(serverKey: String): ImageVector =
when (serverKey) {
"pay" -> Icons.Default.ArrowUpward
"invoice" -> Icons.Default.ArrowDownward
"lookup" -> Icons.Default.Search
"history" -> Icons.Default.List
"balance" -> Icons.Default.AccountBalanceWallet
"info" -> Icons.Default.Info
else -> Icons.Default.Key
}
/**
* Maps the API model [NwcBudget] to the UI domain model [BudgetData].
* refresh_window is widened from Int to Long to match [BudgetData].
*/
internal fun NwcBudget.toBudgetData() = BudgetData(
id = id.toLong(),
pubkey = pubkey,
budgetMsats = budget_msats,
refreshWindow = refresh_window.toLong(),
createdAt = created_at,
usedBudgetMsats = used_budget_msats
)
@@ -1,16 +1,18 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Cable
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
@@ -26,18 +28,19 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.api.NwcBudget
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
// ── Available permissions ─────────────────────────────────────────────────────
private data class NwcPermission(
internal data class NwcPermission(
val serverKey : String, // what we send in the permissions array
val displayLabel : String, // shown in the UI
val default : Boolean // pre-checked by default
)
private val ALL_PERMISSIONS = listOf(
internal val ALL_PERMISSIONS = listOf(
NwcPermission("pay", "Send payments", default = true),
NwcPermission("invoice", "Create invoices", default = true),
NwcPermission("lookup", "Lookup invoice status", default = true),
@@ -52,7 +55,8 @@ private val ALL_PERMISSIONS = listOf(
@Composable
fun NwcScreen(
viewModel : NwcViewModel,
onBack : () -> Unit
onBack : () -> Unit,
onConnectionClick : (pubkey: String) -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val isDeleting by viewModel.isDeleting.collectAsStateWithLifecycle()
@@ -112,18 +116,22 @@ fun NwcScreen(
)
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier.fillMaxSize()
) {
items(
items = state.connections,
key = { it.data.pubkey }
) { connection ->
NwcConnectionCard(
connection = connection,
isDeleting = isDeleting,
onDeleteClick = { pendingDeletePubkey = connection.data.pubkey }
NwcConnectionRow(
connection,
isDeleting,
onDeleteConfirm = { pendingDeletePubkey = connection.data.pubkey },
onConnectionClick = { onConnectionClick(connection.data.pubkey) }
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp
)
}
}
@@ -217,7 +225,7 @@ private fun NwcAddConnectionSheet(
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Label (e.g. Zeus, Alby)") },
label = { Text("Label (e.g. Amethyst, Bitrefill)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
@@ -359,113 +367,125 @@ private fun NwcPairingUrlDialog(
)
}
// ── Connection Card ───────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NwcConnectionCard(
connection : NwcGetResponse,
isDeleting : Boolean,
onDeleteClick: () -> Unit,
modifier : Modifier = Modifier
fun NwcConnectionRow(
connection : NwcGetResponse,
isDeleting : Boolean,
onDeleteConfirm : () -> Unit,
onConnectionClick: () -> Unit, // TODO: open detail screen
modifier : Modifier = Modifier
) {
val key = connection.data
val budgets = connection.budgets
val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond
val semantic = semanticColors()
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (expired)
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
else
MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
// ── Swipe-to-delete state ────────────────────────────────────────────────
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
if (value == SwipeToDismissBoxValue.EndToStart && !isDeleting) {
onDeleteConfirm()
true
} else {
false
}
},
positionalThreshold = { totalDistance -> totalDistance * 0.4f }
)
SwipeToDismissBox(
state = dismissState,
modifier = modifier,
enableDismissFromStartToEnd = false,
enableDismissFromEndToStart = !isDeleting,
backgroundContent = {
// Red delete background revealed on swipe-left
val fraction = dismissState.progress
val bgAlpha = (fraction / 0.4f).coerceIn(0f, 1f)
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.error.copy(alpha = bgAlpha))
.padding(end = 24.dp),
contentAlignment = Alignment.CenterEnd
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = key.description.ifBlank { "Unnamed connection" },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (expired) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete connection",
tint = MaterialTheme.colorScheme.onError.copy(alpha = bgAlpha)
)
}
}
) {
// ── Foreground row ───────────────────────────────────────────────────
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.clickable(onClick = onConnectionClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Leading icon represents a wallet connection / plug
Icon(
imageVector = Icons.Default.Cable, // or Icons.Default.Link
contentDescription = null,
tint = if (expired)
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
else
MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
// Connection name
Text(
text = key.description.ifBlank { "Unnamed connection" },
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (expired)
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
else
MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(2.dp))
// Last-used timestamp
Text(
text = if (key.last_used <= 0L) "Never used"
else "Last used ${formatEpoch(key.last_used)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Expired badge mirrors the pending/failed chip in PaymentRow
if (expired) {
Spacer(Modifier.height(4.dp))
Surface(
shape = MaterialTheme.shapes.extraSmall,
color = semantic.errorContainer,
contentColor = semantic.onErrorContainer
) {
Text(
text = "Expired",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
text = "Expired",
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
IconButton(onClick = onDeleteClick, enabled = !isDeleting) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete connection",
tint = MaterialTheme.colorScheme.error
)
}
}
Text(
text = key.pubkey.take(16) + "" + key.pubkey.takeLast(8),
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
HorizontalDivider()
if (key.permissions.isNotBlank()) {
NwcPermissionChips(permissions = key.permissions)
}
NwcMetaRow(
label = "Expires",
value = if (key.expires_at <= 0L) "Never" else formatEpoch(key.expires_at)
)
NwcMetaRow(
label = "Last used",
value = if (key.last_used <= 0L) "Never" else formatEpoch(key.last_used)
)
if (budgets.isNotEmpty()) {
HorizontalDivider()
budgets.forEach { NwcBudgetRow(budget = it) }
}
}
}
}
// ── Permission chips ──────────────────────────────────────────────────────────
@Composable
private fun NwcPermissionChips(permissions: String, modifier: Modifier = Modifier) {
val perms = remember(permissions) {
permissions.split(",").map { it.trim() }.filter { it.isNotEmpty() }
}
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
perms.forEach { perm ->
SuggestionChip(
onClick = {},
label = {
Text(
text = perm.replace("_", " "),
style = MaterialTheme.typography.labelSmall
)
}
// Subtle chevron signals the row is tappable
Spacer(Modifier.width(8.dp))
Icon(
imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(16.dp)
)
}
}
@@ -499,16 +519,6 @@ private fun NwcBudgetRow(budget: NwcBudget, modifier: Modifier = Modifier) {
}
}
// ── Meta row ──────────────────────────────────────────────────────────────────
@Composable
private fun NwcMetaRow(label: String, value: String, modifier: Modifier = Modifier) {
Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodySmall)
}
}
// ── Empty / Error states ──────────────────────────────────────────────────────
@Composable
@@ -555,5 +565,5 @@ private val dateFormatter: DateTimeFormatter =
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
.withZone(ZoneId.systemDefault())
private fun formatEpoch(epochSeconds: Long): String =
fun formatEpoch(epochSeconds: Long): String =
runCatching { dateFormatter.format(Instant.ofEpochSecond(epochSeconds)) }.getOrDefault("")
@@ -25,6 +25,15 @@ sealed interface NwcUiState {
data class Error(val message: String) : NwcUiState
}
sealed interface ConnectionDetailState {
data object Loading : ConnectionDetailState
data class Success(
val connection : ConnectionData,
val budgets : List<BudgetData>
) : ConnectionDetailState
data class Error(val message: String) : ConnectionDetailState
}
// ── ViewModel ─────────────────────────────────────────────────────────────────
class NwcViewModel(
@@ -51,6 +60,11 @@ class NwcViewModel(
private val _pairingUrl = MutableStateFlow<String?>(null)
val pairingUrl: StateFlow<String?> = _pairingUrl.asStateFlow()
private val _connectionDetailState =
MutableStateFlow<ConnectionDetailState>(ConnectionDetailState.Loading)
val connectionDetailState: StateFlow<ConnectionDetailState> =
_connectionDetailState.asStateFlow()
init {
loadConnections()
}
@@ -164,6 +178,67 @@ class NwcViewModel(
}
}
fun loadConnectionDetail(pubkey: String) {
Timber.d("[$TAG] loadConnectionDetail pubkey=${pubkey.take(16)}")
_connectionDetailState.value = ConnectionDetailState.Loading
viewModelScope.launch {
runCatching {
repo.getNwcKey(
pubkey = pubkey,
includeExpired = true, // show detail even for expired keys
refreshLastUsed = false
)
}
.onSuccess { response ->
Timber.i("[$TAG] loaded connection detail ✓")
_connectionDetailState.value = ConnectionDetailState.Success(
connection = response.data.toConnectionData(),
budgets = response.budgets.map { it.toBudgetData() }
)
}
.onFailure { e ->
Timber.e(e, "[$TAG] failed to load connection detail")
_connectionDetailState.value = ConnectionDetailState.Error(
e.message ?: "Failed to load connection"
)
}
}
}
/** Reset to Loading so the next open always shows a fresh load. */
fun clearConnectionDetail() {
Timber.d("[$TAG] clearConnectionDetail")
_connectionDetailState.value = ConnectionDetailState.Loading
}
/**
* Deletes the key via the repo, then refreshes the list so the revoked
* entry disappears from NwcScreen when the user pops back.
*/
fun revokeConnection(pubkey: String) {
Timber.d("[$TAG] revokeConnection pubkey=${pubkey.take(16)}")
viewModelScope.launch {
runCatching { repo.deleteNwcKey(pubkey) }
.onSuccess {
Timber.i("[$TAG] revoked connection pubkey=${pubkey.take(16)}… ✓")
// Optimistic local removal
val current = _uiState.value
if (current is NwcUiState.Success) {
_uiState.value = NwcUiState.Success(
current.connections.filter { it.data.pubkey != pubkey }
)
}
loadConnections()
}
.onFailure { e ->
Timber.e(e, "[$TAG] failed to revoke connection")
_uiState.value = NwcUiState.Error(
e.message ?: "Failed to revoke connection"
)
}
}
}
// ── Factory ───────────────────────────────────────────────────────────────
class Factory(private val repo: WalletRepository) :