From 4a2b66dcf2b5c86d79f013bb7cbe26057c283b40 Mon Sep 17 00:00:00 2001 From: rasputin Date: Tue, 9 Jun 2026 18:17:32 +0200 Subject: [PATCH] feat: nwc details --- .../gudariwallet/api/LNbitsApi.kt | 2 +- .../data/LNbitsWalletRepository.kt | 2 +- .../gudariwallet/i18n/EnStrings.kt | 47 ++ .../gudariwallet/i18n/EsStrings.kt | 51 +- .../gudariwallet/i18n/EuStrings.kt | 47 ++ .../bitcointxoko/gudariwallet/i18n/Strings.kt | 56 +- .../gudariwallet/ui/WalletScreen.kt | 57 ++ .../gudariwallet/ui/nwc/NwcDetailScreen.kt | 522 ++++++++++++++++++ .../gudariwallet/ui/nwc/NwcMappings.kt | 73 +++ .../gudariwallet/ui/nwc/NwcScreen.kt | 246 +++++---- .../gudariwallet/ui/nwc/NwcViewModel.kt | 75 +++ 11 files changed, 1056 insertions(+), 122 deletions(-) create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcDetailScreen.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcMappings.kt diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsApi.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsApi.kt index b759d08..1693862 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsApi.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsApi.kt @@ -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 diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt index f60c926..1f95a34 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt @@ -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 diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EnStrings.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EnStrings.kt index be3a3ca..3a051df 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EnStrings.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EnStrings.kt @@ -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", ) ) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EsStrings.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EsStrings.kt index cd0aea2..b288053 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EsStrings.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EsStrings.kt @@ -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", + ) ) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EuStrings.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EuStrings.kt index 70f9c6d..b51a605 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EuStrings.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EuStrings.kt @@ -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", ) ) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/Strings.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/Strings.kt index c1efe44..cd20505 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/Strings.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/Strings.kt @@ -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, ) \ No newline at end of file diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt index 3550b0a..8c5516a 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt @@ -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 ──── diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcDetailScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcDetailScreen.kt new file mode 100644 index 0000000..338c3df --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcDetailScreen.kt @@ -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, // 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, + 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, + 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 +) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcMappings.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcMappings.kt new file mode 100644 index 0000000..f22e3e2 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcMappings.kt @@ -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 { + 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 +) \ No newline at end of file diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt index abba8aa..aa4a8c5 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt @@ -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("—") diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcViewModel.kt index d48fd47..436206f 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcViewModel.kt @@ -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 + ) : ConnectionDetailState + data class Error(val message: String) : ConnectionDetailState +} + // ── ViewModel ───────────────────────────────────────────────────────────────── class NwcViewModel( @@ -51,6 +60,11 @@ class NwcViewModel( private val _pairingUrl = MutableStateFlow(null) val pairingUrl: StateFlow = _pairingUrl.asStateFlow() + private val _connectionDetailState = + MutableStateFlow(ConnectionDetailState.Loading) + val connectionDetailState: StateFlow = + _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) :