feat: nwc details
This commit is contained in:
@@ -162,7 +162,7 @@ interface LNbitsApi {
|
|||||||
|
|
||||||
@GET("nwcprovider/api/v1/nwc/{pubkey}")
|
@GET("nwcprovider/api/v1/nwc/{pubkey}")
|
||||||
suspend fun getNwcKey(
|
suspend fun getNwcKey(
|
||||||
@Header("X-Api-Key") apiKey: String,
|
@Header("X-Api-Key") adminKey: String,
|
||||||
@Path("pubkey") pubkey: String,
|
@Path("pubkey") pubkey: String,
|
||||||
@Query("include_expired") includeExpired: Boolean = false,
|
@Query("include_expired") includeExpired: Boolean = false,
|
||||||
@Query("refresh_last_used") refreshLastUsed: Boolean = false
|
@Query("refresh_last_used") refreshLastUsed: Boolean = false
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ class LNbitsWalletRepository(
|
|||||||
): NwcGetResponse {
|
): NwcGetResponse {
|
||||||
Timber.d("NWC [KEY ] fetching key pubkey=${pubkey.take(16)}…")
|
Timber.d("NWC [KEY ] fetching key pubkey=${pubkey.take(16)}…")
|
||||||
val result = api.getNwcKey(
|
val result = api.getNwcKey(
|
||||||
apiKey = secrets.invoiceKey(),
|
adminKey = secrets.adminKey(),
|
||||||
pubkey = pubkey,
|
pubkey = pubkey,
|
||||||
includeExpired = includeExpired,
|
includeExpired = includeExpired,
|
||||||
refreshLastUsed = refreshLastUsed
|
refreshLastUsed = refreshLastUsed
|
||||||
|
|||||||
@@ -352,6 +352,53 @@ val EnHomeStrings = AppStrings(
|
|||||||
scheduleSync = "Schedule sync",
|
scheduleSync = "Schedule sync",
|
||||||
schedule = "Schedule",
|
schedule = "Schedule",
|
||||||
cancel = "Cancel"
|
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
|
package com.bitcointxoko.gudariwallet.i18n
|
||||||
|
|
||||||
import cafe.adriel.lyricist.LyricistStrings
|
import cafe.adriel.lyricist.LyricistStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.i18n.NwcStrings
|
||||||
|
|
||||||
@LyricistStrings(languageTag = "es")
|
@LyricistStrings(languageTag = "es")
|
||||||
val EsHomeStrings = AppStrings(
|
val EsHomeStrings = AppStrings(
|
||||||
@@ -352,5 +353,53 @@ val EsHomeStrings = AppStrings(
|
|||||||
scheduleSync = "Programar sincronización",
|
scheduleSync = "Programar sincronización",
|
||||||
schedule = "Programar",
|
schedule = "Programar",
|
||||||
cancel = "Cancelar"
|
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",
|
scheduleSync = "Programatu sinkronizazioa",
|
||||||
schedule = "Programatu",
|
schedule = "Programatu",
|
||||||
cancel = "Utzi"
|
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 paymentRowPending : String,
|
||||||
val paymentRowFailed : String,
|
val paymentRowFailed : String,
|
||||||
|
|
||||||
val onboarding: OnboardingStrings
|
val onboarding: OnboardingStrings,
|
||||||
|
val nwc: NwcStrings
|
||||||
)
|
)
|
||||||
|
|
||||||
data class OnboardingStrings(
|
data class OnboardingStrings(
|
||||||
@@ -351,4 +352,57 @@ data class OnboardingStrings(
|
|||||||
val scheduleSync: String,
|
val scheduleSync: String,
|
||||||
val schedule: String,
|
val schedule: String,
|
||||||
val cancel: 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.HistoryViewModelFactory
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
|
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
|
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.NwcScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.nwc.NwcViewModel
|
import com.bitcointxoko.gudariwallet.ui.nwc.NwcViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
|
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_HISTORY = "history"
|
||||||
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
|
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
|
||||||
private const val ROUTE_NWC = "nwc"
|
private const val ROUTE_NWC = "nwc"
|
||||||
|
private const val ROUTE_CONNECTION_DETAIL = "connection_detail/{pubkey}"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WalletScreen(
|
fun WalletScreen(
|
||||||
@@ -134,11 +137,13 @@ fun WalletScreen(
|
|||||||
&& currentRoute != ROUTE_SCANNER
|
&& currentRoute != ROUTE_SCANNER
|
||||||
&& currentRoute != ROUTE_HISTORY
|
&& currentRoute != ROUTE_HISTORY
|
||||||
&& currentRoute != ROUTE_NWC
|
&& currentRoute != ROUTE_NWC
|
||||||
|
&& currentRoute != ROUTE_CONNECTION_DETAIL
|
||||||
|
|
||||||
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|
||||||
|| currentRoute == ROUTE_PAYMENT_DETAIL
|
|| currentRoute == ROUTE_PAYMENT_DETAIL
|
||||||
|| currentRoute == ROUTE_SCANNER
|
|| currentRoute == ROUTE_SCANNER
|
||||||
|| currentRoute == ROUTE_NWC
|
|| currentRoute == ROUTE_NWC
|
||||||
|
|| currentRoute == ROUTE_CONNECTION_DETAIL
|
||||||
|
|
||||||
// ── Notification tap ──────────────────────────────────────────────────────
|
// ── Notification tap ──────────────────────────────────────────────────────
|
||||||
val hash = pendingPaymentHash.value
|
val hash = pendingPaymentHash.value
|
||||||
@@ -437,9 +442,61 @@ fun WalletScreen(
|
|||||||
viewModel = nwcVm,
|
viewModel = nwcVm,
|
||||||
onBack = {
|
onBack = {
|
||||||
navController.popBackStack(ROUTE_HOME, inclusive = false)
|
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 ────
|
// ── 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
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.Add
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
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.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
@@ -26,18 +28,19 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.bitcointxoko.gudariwallet.api.NwcBudget
|
import com.bitcointxoko.gudariwallet.api.NwcBudget
|
||||||
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
|
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
// ── Available permissions ─────────────────────────────────────────────────────
|
// ── Available permissions ─────────────────────────────────────────────────────
|
||||||
private data class NwcPermission(
|
internal data class NwcPermission(
|
||||||
val serverKey : String, // what we send in the permissions array
|
val serverKey : String, // what we send in the permissions array
|
||||||
val displayLabel : String, // shown in the UI
|
val displayLabel : String, // shown in the UI
|
||||||
val default : Boolean // pre-checked by default
|
val default : Boolean // pre-checked by default
|
||||||
)
|
)
|
||||||
private val ALL_PERMISSIONS = listOf(
|
internal val ALL_PERMISSIONS = listOf(
|
||||||
NwcPermission("pay", "Send payments", default = true),
|
NwcPermission("pay", "Send payments", default = true),
|
||||||
NwcPermission("invoice", "Create invoices", default = true),
|
NwcPermission("invoice", "Create invoices", default = true),
|
||||||
NwcPermission("lookup", "Lookup invoice status", default = true),
|
NwcPermission("lookup", "Lookup invoice status", default = true),
|
||||||
@@ -52,7 +55,8 @@ private val ALL_PERMISSIONS = listOf(
|
|||||||
@Composable
|
@Composable
|
||||||
fun NwcScreen(
|
fun NwcScreen(
|
||||||
viewModel : NwcViewModel,
|
viewModel : NwcViewModel,
|
||||||
onBack : () -> Unit
|
onBack : () -> Unit,
|
||||||
|
onConnectionClick : (pubkey: String) -> Unit
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val isDeleting by viewModel.isDeleting.collectAsStateWithLifecycle()
|
val isDeleting by viewModel.isDeleting.collectAsStateWithLifecycle()
|
||||||
@@ -112,18 +116,22 @@ fun NwcScreen(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
items = state.connections,
|
items = state.connections,
|
||||||
key = { it.data.pubkey }
|
key = { it.data.pubkey }
|
||||||
) { connection ->
|
) { connection ->
|
||||||
NwcConnectionCard(
|
NwcConnectionRow(
|
||||||
connection = connection,
|
connection,
|
||||||
isDeleting = isDeleting,
|
isDeleting,
|
||||||
onDeleteClick = { pendingDeletePubkey = connection.data.pubkey }
|
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(
|
OutlinedTextField(
|
||||||
value = description,
|
value = description,
|
||||||
onValueChange = { description = it },
|
onValueChange = { description = it },
|
||||||
label = { Text("Label (e.g. Zeus, Alby)") },
|
label = { Text("Label (e.g. Amethyst, Bitrefill)") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
@@ -359,113 +367,125 @@ private fun NwcPairingUrlDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Connection Card ───────────────────────────────────────────────────────────
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NwcConnectionCard(
|
fun NwcConnectionRow(
|
||||||
connection : NwcGetResponse,
|
connection : NwcGetResponse,
|
||||||
isDeleting : Boolean,
|
isDeleting : Boolean,
|
||||||
onDeleteClick: () -> Unit,
|
onDeleteConfirm : () -> Unit,
|
||||||
modifier : Modifier = Modifier
|
onConnectionClick: () -> Unit, // TODO: open detail screen
|
||||||
|
modifier : Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val key = connection.data
|
val key = connection.data
|
||||||
val budgets = connection.budgets
|
|
||||||
val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond
|
val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond
|
||||||
|
val semantic = semanticColors()
|
||||||
|
|
||||||
Card(
|
// ── Swipe-to-delete state ────────────────────────────────────────────────
|
||||||
modifier = modifier.fillMaxWidth(),
|
val dismissState = rememberSwipeToDismissBoxState(
|
||||||
colors = CardDefaults.cardColors(
|
confirmValueChange = { value ->
|
||||||
containerColor = if (expired)
|
if (value == SwipeToDismissBoxValue.EndToStart && !isDeleting) {
|
||||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
onDeleteConfirm()
|
||||||
else
|
true
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
} else {
|
||||||
)
|
false
|
||||||
) {
|
}
|
||||||
Column(
|
},
|
||||||
modifier = Modifier
|
positionalThreshold = { totalDistance -> totalDistance * 0.4f }
|
||||||
.fillMaxWidth()
|
)
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
SwipeToDismissBox(
|
||||||
) {
|
state = dismissState,
|
||||||
Row(
|
modifier = modifier,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
enableDismissFromStartToEnd = false,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
enableDismissFromEndToStart = !isDeleting,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
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)) {
|
Icon(
|
||||||
Text(
|
imageVector = Icons.Default.Delete,
|
||||||
text = key.description.ifBlank { "Unnamed connection" },
|
contentDescription = "Delete connection",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
tint = MaterialTheme.colorScheme.onError.copy(alpha = bgAlpha)
|
||||||
maxLines = 1,
|
)
|
||||||
overflow = TextOverflow.Ellipsis
|
}
|
||||||
)
|
}
|
||||||
if (expired) {
|
) {
|
||||||
|
// ── 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(
|
||||||
text = "Expired",
|
text = "Expired",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.error
|
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(
|
// Subtle chevron – signals the row is tappable
|
||||||
text = key.pubkey.take(16) + "…" + key.pubkey.takeLast(8),
|
Spacer(Modifier.width(8.dp))
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
Icon(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight,
|
||||||
)
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
HorizontalDivider()
|
modifier = Modifier.size(16.dp)
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 ──────────────────────────────────────────────────────
|
// ── Empty / Error states ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -555,5 +565,5 @@ private val dateFormatter: DateTimeFormatter =
|
|||||||
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
|
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
|
||||||
.withZone(ZoneId.systemDefault())
|
.withZone(ZoneId.systemDefault())
|
||||||
|
|
||||||
private fun formatEpoch(epochSeconds: Long): String =
|
fun formatEpoch(epochSeconds: Long): String =
|
||||||
runCatching { dateFormatter.format(Instant.ofEpochSecond(epochSeconds)) }.getOrDefault("—")
|
runCatching { dateFormatter.format(Instant.ofEpochSecond(epochSeconds)) }.getOrDefault("—")
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ sealed interface NwcUiState {
|
|||||||
data class Error(val message: String) : 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 ─────────────────────────────────────────────────────────────────
|
// ── ViewModel ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class NwcViewModel(
|
class NwcViewModel(
|
||||||
@@ -51,6 +60,11 @@ class NwcViewModel(
|
|||||||
private val _pairingUrl = MutableStateFlow<String?>(null)
|
private val _pairingUrl = MutableStateFlow<String?>(null)
|
||||||
val pairingUrl: StateFlow<String?> = _pairingUrl.asStateFlow()
|
val pairingUrl: StateFlow<String?> = _pairingUrl.asStateFlow()
|
||||||
|
|
||||||
|
private val _connectionDetailState =
|
||||||
|
MutableStateFlow<ConnectionDetailState>(ConnectionDetailState.Loading)
|
||||||
|
val connectionDetailState: StateFlow<ConnectionDetailState> =
|
||||||
|
_connectionDetailState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadConnections()
|
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 ───────────────────────────────────────────────────────────────
|
// ── Factory ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class Factory(private val repo: WalletRepository) :
|
class Factory(private val repo: WalletRepository) :
|
||||||
|
|||||||
Reference in New Issue
Block a user