@@ -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 ( ) && !is Creating ,
@@ -382,24 +504,42 @@ fun NwcConnectionRow(
// ── Swipe-to-delete state ────────────────────────────────────────────────
val dismissState = rememberSwipeToDismissBoxState (
confirmValueChange = { value ->
if ( value == SwipeToDismissBoxValue . EndToStart && !is Deleting ) {
onDeleteConfirm ( )
true
} else {
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
&& !is Deleting
&& ! 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 ,
enableDismissFromStartToEnd = false ,
enableDismissFromEndToStart = !is Deleting ,
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