feat: nwc details
This commit is contained in:
@@ -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(
|
||||
@@ -352,3 +353,56 @@ data class OnboardingStrings(
|
||||
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(
|
||||
fun NwcConnectionRow(
|
||||
connection : NwcGetResponse,
|
||||
isDeleting : Boolean,
|
||||
onDeleteClick: () -> Unit,
|
||||
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
|
||||
// ── Swipe-to-delete state ────────────────────────────────────────────────
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = { value ->
|
||||
if (value == SwipeToDismissBoxValue.EndToStart && !isDeleting) {
|
||||
onDeleteConfirm()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
positionalThreshold = { totalDistance -> totalDistance * 0.4f }
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
|
||||
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
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.error.copy(alpha = bgAlpha))
|
||||
.padding(end = 24.dp),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = key.description.ifBlank { "Unnamed connection" },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (expired) {
|
||||
Text(
|
||||
text = "Expired",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDeleteClick, enabled = !isDeleting) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Delete connection",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
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.pubkey.take(16) + "…" + key.pubkey.takeLast(8),
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
// 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
|
||||
) {
|
||||
perms.forEach { perm ->
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = {
|
||||
Text(
|
||||
text = perm.replace("_", " "),
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
text = "Expired",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) :
|
||||
|
||||
Reference in New Issue
Block a user