fix: nwc ui

This commit is contained in:
2026-06-10 12:40:02 +02:00
parent 2ff28720f8
commit 05a31070e8
4 changed files with 191 additions and 93 deletions
@@ -415,5 +415,4 @@ class LNbitsWalletRepository(
Timber.i("NWC [CONFIG SET] config updated ✓") Timber.i("NWC [CONFIG SET] config updated ✓")
return result return result
} }
} }
@@ -109,9 +109,8 @@ class PaymentCacheRepository(context: Context) {
minCreatedAt = filter.minCreatedAt, minCreatedAt = filter.minCreatedAt,
maxCreatedAt = filter.maxCreatedAt maxCreatedAt = filter.maxCreatedAt
).map { it.toDomain() }.also { ).map { it.toDomain() }.also {
Timber.d("PAYMENTS [QUERY ] q=$searchQuery status=$statusPattern " + Timber.d("%snull", "PAYMENTS [QUERY ] q=$searchQuery status=$statusPattern " +
"amt=${filter.minAmountSat}${filter.maxAmountSat} " + "amt=${filter.minAmountSat}${filter.maxAmountSat} ")
"date=${filter.minCreatedAt}${filter.maxCreatedAt}${it.size} rows")
} }
} }
@@ -324,8 +324,8 @@ private fun BudgetCard(budget: BudgetData) {
val totalSats = budget.budgetMsats / 1_000L val totalSats = budget.budgetMsats / 1_000L
val usedSats = budget.usedBudgetMsats / 1_000L val usedSats = budget.usedBudgetMsats / 1_000L
val progress = if (budget.budgetMsats > 0) val progress = if (budget.budgetMsats > 0)
(budget.usedBudgetMsats.toFloat() / budget.budgetMsats.toFloat()).coerceIn(0f, 1f) (1f - budget.usedBudgetMsats.toFloat() / budget.budgetMsats.toFloat()).coerceIn(0f, 1f)
else 0f else 0f
val isOverBudget = budget.usedBudgetMsats > budget.budgetMsats val isOverBudget = budget.usedBudgetMsats > budget.budgetMsats
@@ -9,12 +9,14 @@ 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.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 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.Cable import androidx.compose.material.icons.filled.Cable
import androidx.compose.material.icons.filled.CalendarToday
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.material.icons.filled.Schedule
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -26,13 +28,15 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.NwcGetResponse import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import java.text.SimpleDateFormat
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
import java.util.Calendar
import java.util.Locale
// ── Available permissions ───────────────────────────────────────────────────── // ── Available permissions ─────────────────────────────────────────────────────
internal data class NwcPermission( internal data class NwcPermission(
@@ -42,11 +46,11 @@ internal data class NwcPermission(
) )
internal 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 = false),
NwcPermission("lookup", "Lookup invoice status", default = true), NwcPermission("lookup", "Lookup invoice status", default = false),
NwcPermission("history", "Read transaction history", default = true), NwcPermission("history", "Read transaction history", default = false),
NwcPermission("balance", "Read wallet balance", default = true), NwcPermission("balance", "Read wallet balance", default = false),
NwcPermission("info", "Read account info", default = true) NwcPermission("info", "Read account info", default = false)
) )
// ── Screen ──────────────────────────────────────────────────────────────────── // ── Screen ────────────────────────────────────────────────────────────────────
@@ -72,7 +76,7 @@ fun NwcScreen(
title = { Text("Wallet Connect") }, title = { Text("Wallet Connect") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
} }
} }
) )
@@ -88,7 +92,7 @@ fun NwcScreen(
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
onRefresh = { viewModel.loadConnections() }, onRefresh = { viewModel.loadConnections(includeExpired = true) },
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding)
@@ -103,7 +107,7 @@ fun NwcScreen(
is NwcUiState.Error -> { is NwcUiState.Error -> {
NwcErrorContent( NwcErrorContent(
message = state.message, message = state.message,
onRetry = { viewModel.loadConnections() }, onRetry = { viewModel.loadConnections(includeExpired = true) },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
@@ -201,7 +205,75 @@ private fun NwcAddConnectionSheet(
var selectedPermissions by remember { var selectedPermissions by remember {
mutableStateOf(ALL_PERMISSIONS.filter { it.default }.map { it.serverKey }.toSet()) mutableStateOf(ALL_PERMISSIONS.filter { it.default }.map { it.serverKey }.toSet())
} }
var neverExpires by remember { mutableStateOf(true) } var neverExpires by remember { mutableStateOf(false) }
// Default expiry = now + 30 days, truncated to the minute
val defaultExpiry = remember {
Calendar.getInstance().apply {
add(Calendar.DAY_OF_YEAR, 30)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
}
var expiryCalendar by remember { mutableStateOf(defaultExpiry) }
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
// ── Date picker dialog ────────────────────────────────────────────────────
if (showDatePicker) {
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = expiryCalendar.timeInMillis
)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
datePickerState.selectedDateMillis?.let { millis ->
expiryCalendar = (expiryCalendar.clone() as Calendar).apply {
val picked = Calendar.getInstance().apply { timeInMillis = millis }
set(Calendar.YEAR, picked.get(Calendar.YEAR))
set(Calendar.MONTH, picked.get(Calendar.MONTH))
set(Calendar.DAY_OF_MONTH, picked.get(Calendar.DAY_OF_MONTH))
}
}
showDatePicker = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) { Text("Cancel") }
}
) {
DatePicker(state = datePickerState)
}
}
// ── Time picker dialog ────────────────────────────────────────────────────
if (showTimePicker) {
val timePickerState = rememberTimePickerState(
initialHour = expiryCalendar.get(Calendar.HOUR_OF_DAY),
initialMinute = expiryCalendar.get(Calendar.MINUTE),
is24Hour = true
)
AlertDialog(
onDismissRequest = { showTimePicker = false },
confirmButton = {
TextButton(onClick = {
expiryCalendar = (expiryCalendar.clone() as Calendar).apply {
set(Calendar.HOUR_OF_DAY, timePickerState.hour)
set(Calendar.MINUTE, timePickerState.minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
showTimePicker = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showTimePicker = false }) { Text("Cancel") }
},
text = { TimePicker(state = timePickerState) }
)
}
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -215,7 +287,6 @@ private fun NwcAddConnectionSheet(
.padding(bottom = 32.dp), .padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text( Text(
text = "New connection", text = "New connection",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
@@ -230,6 +301,72 @@ private fun NwcAddConnectionSheet(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
// ── Expiry ────────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
// horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = "Expires",
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.weight(1f))
// Date chip — hidden when "Never" is checked
if (!neverExpires) {
val dateLabel = remember(expiryCalendar) {
SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
.format(expiryCalendar.time)
}
SuggestionChip(
onClick = { showDatePicker = true },
label = { Text(dateLabel, style = MaterialTheme.typography.bodySmall) },
icon = {
Icon(
imageVector = Icons.Default.CalendarToday,
contentDescription = "Pick date",
modifier = Modifier.size(16.dp)
)
}
)
Spacer(Modifier.weight(1f))
// Time chip
val timeLabel = remember(expiryCalendar) {
String.format(
"%02d:%02d",
expiryCalendar.get(Calendar.HOUR_OF_DAY),
expiryCalendar.get(Calendar.MINUTE)
)
}
SuggestionChip(
onClick = { showTimePicker = true },
label = { Text(timeLabel, style = MaterialTheme.typography.bodySmall) },
icon = {
Icon(
imageVector = Icons.Default.Schedule,
contentDescription = "Pick time",
modifier = Modifier.size(16.dp)
)
}
)
}
Spacer(Modifier.weight(1f))
Checkbox(
checked = neverExpires,
onCheckedChange = { neverExpires = it }
)
Text(
text = "Never",
style = MaterialTheme.typography.bodyMedium
)
}
// ── Permissions ─────────────────────────────────────────────── // ── Permissions ───────────────────────────────────────────────
Text( Text(
text = "Permissions", text = "Permissions",
@@ -259,30 +396,15 @@ private fun NwcAddConnectionSheet(
} }
} }
// ── Expiry ────────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = neverExpires,
onCheckedChange = { neverExpires = it }
)
Spacer(Modifier.width(8.dp))
Text(
text = "Never expires",
style = MaterialTheme.typography.bodyMedium
)
}
// ── Create button ───────────────────────────────────────────── // ── Create button ─────────────────────────────────────────────
Button( Button(
onClick = { onClick = {
val expiresAtSecs = if (neverExpires) 0L
else expiryCalendar.timeInMillis / 1000L
onCreate( onCreate(
description.trim().ifBlank { "Unnamed" }, description.trim().ifBlank { "Unnamed" },
selectedPermissions.toList(), selectedPermissions.toList(),
if (neverExpires) 0L else 0L // expiry picker can be added later expiresAtSecs
) )
}, },
enabled = selectedPermissions.isNotEmpty() && !isCreating, enabled = selectedPermissions.isNotEmpty() && !isCreating,
@@ -290,9 +412,9 @@ private fun NwcAddConnectionSheet(
) { ) {
if (isCreating) { if (isCreating) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
strokeWidth = 2.dp, strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary color = MaterialTheme.colorScheme.onPrimary
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
} }
@@ -370,11 +492,11 @@ private fun NwcPairingUrlDialog(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NwcConnectionRow( fun NwcConnectionRow(
connection : NwcGetResponse, connection : NwcGetResponse,
isDeleting : Boolean, isDeleting : Boolean,
onDeleteConfirm : () -> Unit, onDeleteConfirm : () -> Unit,
onConnectionClick: () -> Unit, onConnectionClick: () -> Unit,
modifier : Modifier = Modifier modifier : Modifier = Modifier
) { ) {
val key = connection.data val key = connection.data
val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond
@@ -382,24 +504,42 @@ fun NwcConnectionRow(
// ── Swipe-to-delete state ──────────────────────────────────────────────── // ── Swipe-to-delete state ────────────────────────────────────────────────
val dismissState = rememberSwipeToDismissBoxState( val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value -> confirmValueChange = {
if (value == SwipeToDismissBoxValue.EndToStart && !isDeleting) { // Always reject — we never want the row to stay dismissed.
onDeleteConfirm() // The dialog is triggered via LaunchedEffect on targetValue below.
true false
} else {
false
}
}, },
positionalThreshold = { totalDistance -> totalDistance * 0.4f } positionalThreshold = { totalDistance -> totalDistance * 0.4f }
) )
// targetValue changes to EndToStart as soon as the drag crosses the
// threshold, even though confirmValueChange returns false and currentValue
// stays at Settled. Use a flag so we only fire once per swipe gesture.
var dialogTriggered by remember { mutableStateOf(false) }
LaunchedEffect(dismissState.targetValue) {
if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart
&& !isDeleting
&& !dialogTriggered
) {
dialogTriggered = true
onDeleteConfirm()
// Snap the row back to its resting position.
dismissState.reset()
}
// Reset the flag once the swipe returns to Settled so the
// gesture can be triggered again if the user cancels and re-swipes.
if (dismissState.targetValue == SwipeToDismissBoxValue.Settled) {
dialogTriggered = false
}
}
SwipeToDismissBox( SwipeToDismissBox(
state = dismissState, state = dismissState,
modifier = modifier, modifier = modifier,
enableDismissFromStartToEnd = false, enableDismissFromStartToEnd = false,
enableDismissFromEndToStart = !isDeleting, enableDismissFromEndToStart = !isDeleting,
backgroundContent = { backgroundContent = {
// Red delete background revealed on swipe-left
val fraction = dismissState.progress val fraction = dismissState.progress
val bgAlpha = (fraction / 0.4f).coerceIn(0f, 1f) val bgAlpha = (fraction / 0.4f).coerceIn(0f, 1f)
Box( Box(
@@ -417,7 +557,6 @@ fun NwcConnectionRow(
} }
} }
) { ) {
// ── Foreground row ───────────────────────────────────────────────────
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -426,9 +565,8 @@ fun NwcConnectionRow(
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Leading icon represents a wallet connection / plug
Icon( Icon(
imageVector = Icons.Default.Cable, // or Icons.Default.Link imageVector = Icons.Default.Cable,
contentDescription = null, contentDescription = null,
tint = if (expired) tint = if (expired)
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
@@ -436,11 +574,8 @@ fun NwcConnectionRow(
MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
// Connection name
Text( Text(
text = key.description.ifBlank { "Unnamed connection" }, text = key.description.ifBlank { "Unnamed connection" },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -451,18 +586,13 @@ fun NwcConnectionRow(
else else
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
) )
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
// Last-used timestamp
Text( Text(
text = if (key.last_used <= 0L) "Never used" text = if (key.last_used <= 0L) "Never used"
else "Last used ${formatEpoch(key.last_used)}", else "Last used ${formatEpoch(key.last_used)}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
// Expired badge mirrors the pending/failed chip in PaymentRow
if (expired) { if (expired) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Surface( Surface(
@@ -478,8 +608,6 @@ fun NwcConnectionRow(
} }
} }
} }
// Subtle chevron signals the row is tappable
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Icon( Icon(
imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight, imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight,
@@ -491,34 +619,6 @@ fun NwcConnectionRow(
} }
} }
// ── Budget row ────────────────────────────────────────────────────────────────
@Composable
private fun NwcBudgetRow(budget: NwcBudget, modifier: Modifier = Modifier) {
val totalSats = budget.budget_msats / 1_000L
val usedSats = budget.used_budget_msats / 1_000L
val progress = if (totalSats > 0) (usedSats.toFloat() / totalSats.toFloat()).coerceIn(0f, 1f) else 0f
val windowHours = budget.refresh_window / 3600
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Budget (${windowHours}h window)", style = MaterialTheme.typography.labelMedium)
Text(
"$usedSats / $totalSats sats",
style = MaterialTheme.typography.labelMedium,
color = if (progress >= 1f) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth().height(6.dp),
color = if (progress >= 1f) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.primary
)
}
}
// ── Empty / Error states ────────────────────────────────────────────────────── // ── Empty / Error states ──────────────────────────────────────────────────────
@Composable @Composable