From 05a31070e82f4ab445947ee294ffec1d58d478fa Mon Sep 17 00:00:00 2001 From: rasputin Date: Wed, 10 Jun 2026 12:40:02 +0200 Subject: [PATCH] fix: nwc ui --- .../data/LNbitsWalletRepository.kt | 1 - .../data/PaymentCacheRepository.kt | 5 +- .../gudariwallet/ui/nwc/NwcDetailScreen.kt | 4 +- .../gudariwallet/ui/nwc/NwcScreen.kt | 274 ++++++++++++------ 4 files changed, 191 insertions(+), 93 deletions(-) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt index 1f95a34..d279664 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt @@ -415,5 +415,4 @@ class LNbitsWalletRepository( Timber.i("NWC [CONFIG SET] config updated ✓") return result } - } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt index 85f5442..9e72464 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt @@ -109,9 +109,8 @@ class PaymentCacheRepository(context: Context) { minCreatedAt = filter.minCreatedAt, maxCreatedAt = filter.maxCreatedAt ).map { it.toDomain() }.also { - Timber.d("PAYMENTS [QUERY ] q=$searchQuery status=$statusPattern " + - "amt=${filter.minAmountSat}–${filter.maxAmountSat} " + - "date=${filter.minCreatedAt}–${filter.maxCreatedAt} → ${it.size} rows") + Timber.d("%snull", "PAYMENTS [QUERY ] q=$searchQuery status=$statusPattern " + + "amt=${filter.minAmountSat}–${filter.maxAmountSat} ") } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcDetailScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcDetailScreen.kt index 5cbc3d1..6e5eac6 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcDetailScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcDetailScreen.kt @@ -324,8 +324,8 @@ private fun BudgetCard(budget: BudgetData) { 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) + val progress = if (budget.budgetMsats > 0) + (1f - budget.usedBudgetMsats.toFloat() / budget.budgetMsats.toFloat()).coerceIn(0f, 1f) else 0f val isOverBudget = budget.usedBudgetMsats > budget.budgetMsats diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt index d2ee61c..e426c59 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt @@ -9,12 +9,14 @@ 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.ArrowBack 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.CalendarToday import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Schedule import androidx.compose.material3.* import androidx.compose.material3.pulltorefresh.PullToRefreshBox 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.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.text.SimpleDateFormat import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle +import java.util.Calendar +import java.util.Locale // ── Available permissions ───────────────────────────────────────────────────── internal data class NwcPermission( @@ -42,11 +46,11 @@ internal data class NwcPermission( ) internal val ALL_PERMISSIONS = listOf( NwcPermission("pay", "Send payments", default = true), - NwcPermission("invoice", "Create invoices", default = true), - NwcPermission("lookup", "Lookup invoice status", default = true), - NwcPermission("history", "Read transaction history", default = true), - NwcPermission("balance", "Read wallet balance", default = true), - NwcPermission("info", "Read account info", default = true) + NwcPermission("invoice", "Create invoices", default = false), + NwcPermission("lookup", "Lookup invoice status", default = false), + NwcPermission("history", "Read transaction history", default = false), + NwcPermission("balance", "Read wallet balance", default = false), + NwcPermission("info", "Read account info", default = false) ) // ── Screen ──────────────────────────────────────────────────────────────────── @@ -72,7 +76,7 @@ fun NwcScreen( title = { Text("Wallet Connect") }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } } ) @@ -88,7 +92,7 @@ fun NwcScreen( PullToRefreshBox( isRefreshing = isRefreshing, - onRefresh = { viewModel.loadConnections() }, + onRefresh = { viewModel.loadConnections(includeExpired = true) }, modifier = Modifier .fillMaxSize() .padding(innerPadding) @@ -103,7 +107,7 @@ fun NwcScreen( is NwcUiState.Error -> { NwcErrorContent( message = state.message, - onRetry = { viewModel.loadConnections() }, + onRetry = { viewModel.loadConnections(includeExpired = true) }, modifier = Modifier.fillMaxSize() ) } @@ -201,7 +205,75 @@ private fun NwcAddConnectionSheet( var selectedPermissions by remember { 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( onDismissRequest = onDismiss, @@ -215,7 +287,6 @@ private fun NwcAddConnectionSheet( .padding(bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text( text = "New connection", style = MaterialTheme.typography.titleMedium @@ -230,6 +301,72 @@ private fun NwcAddConnectionSheet( 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 ─────────────────────────────────────────────── Text( 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 ───────────────────────────────────────────── Button( onClick = { + val expiresAtSecs = if (neverExpires) 0L + else expiryCalendar.timeInMillis / 1000L onCreate( description.trim().ifBlank { "Unnamed" }, selectedPermissions.toList(), - if (neverExpires) 0L else 0L // expiry picker can be added later + expiresAtSecs ) }, enabled = selectedPermissions.isNotEmpty() && !isCreating, @@ -290,9 +412,9 @@ private fun NwcAddConnectionSheet( ) { if (isCreating) { CircularProgressIndicator( - modifier = Modifier.size(18.dp), + modifier = Modifier.size(18.dp), strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary + color = MaterialTheme.colorScheme.onPrimary ) Spacer(Modifier.width(8.dp)) } @@ -370,11 +492,11 @@ private fun NwcPairingUrlDialog( @OptIn(ExperimentalMaterial3Api::class) @Composable fun NwcConnectionRow( - connection : NwcGetResponse, - isDeleting : Boolean, - onDeleteConfirm : () -> Unit, + connection : NwcGetResponse, + isDeleting : Boolean, + onDeleteConfirm : () -> Unit, onConnectionClick: () -> Unit, - modifier : Modifier = Modifier + modifier : Modifier = Modifier ) { val key = connection.data val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond @@ -382,24 +504,42 @@ fun NwcConnectionRow( // ── Swipe-to-delete state ──────────────────────────────────────────────── val dismissState = rememberSwipeToDismissBoxState( - confirmValueChange = { value -> - if (value == SwipeToDismissBoxValue.EndToStart && !isDeleting) { - onDeleteConfirm() - true - } else { - false - } + confirmValueChange = { + // Always reject — we never want the row to stay dismissed. + // The dialog is triggered via LaunchedEffect on targetValue below. + false }, 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( - state = dismissState, - modifier = modifier, + 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( @@ -417,7 +557,6 @@ fun NwcConnectionRow( } } ) { - // ── Foreground row ─────────────────────────────────────────────────── Row( modifier = Modifier .fillMaxWidth() @@ -426,9 +565,8 @@ fun NwcConnectionRow( .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 + imageVector = Icons.Default.Cable, contentDescription = null, tint = if (expired) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) @@ -436,11 +574,8 @@ fun NwcConnectionRow( 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, @@ -451,18 +586,13 @@ fun NwcConnectionRow( 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( @@ -478,8 +608,6 @@ fun NwcConnectionRow( } } } - - // Subtle chevron – signals the row is tappable Spacer(Modifier.width(8.dp)) Icon( 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 ────────────────────────────────────────────────────── @Composable