diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml
index 19ba699..884b7ad 100644
--- a/.idea/dictionaries/project.xml
+++ b/.idea/dictionaries/project.xml
@@ -3,6 +3,7 @@
Aead
Nbits
+ Nostr
Tink
amboss
hkdf
@@ -13,6 +14,7 @@
msat
satoshis
sats
+ snackbar
\ No newline at end of file
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcAddConnectionSheet.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcAddConnectionSheet.kt
index 6d8c970..7f3cf25 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcAddConnectionSheet.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcAddConnectionSheet.kt
@@ -16,11 +16,43 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.NwcNewBudget
-import java.text.SimpleDateFormat
-import java.util.Calendar
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
import java.util.Locale
-// ── Add connection bottom sheet ──────────────────────────────────────────────
+@Stable
+private class AddConnectionState {
+ var description by mutableStateOf("")
+ var selectedPermissions by mutableStateOf(
+ ALL_PERMISSIONS.filter { it.default }.map { it.serverKey }.toSet()
+ )
+ var neverExpires by mutableStateOf(false)
+ var expiry: LocalDateTime by mutableStateOf(
+ LocalDateTime.now().plusDays(30).withSecond(0).withNano(0)
+ )
+ var showDatePicker by mutableStateOf(false)
+ var showTimePicker by mutableStateOf(false)
+ var nextBudgetId by mutableIntStateOf(0)
+ var budgets by mutableStateOf(emptyList())
+
+ // Converts the LocalDateTime expiry to a Unix epoch in seconds (#10)
+ fun expiryEpochSeconds(): Long =
+ expiry.atZone(ZoneId.systemDefault()).toEpochSecond()
+
+ fun reset() {
+ description = ""
+ selectedPermissions = ALL_PERMISSIONS.filter { it.default }.map { it.serverKey }.toSet()
+ neverExpires = false
+ expiry = LocalDateTime.now().plusDays(30).withSecond(0).withNano(0)
+ showDatePicker = false
+ showTimePicker = false
+ nextBudgetId = 0
+ budgets = emptyList()
+ }
+}
+
+// ── Sheet ─────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -35,76 +67,59 @@ internal fun NwcAddConnectionSheet(
onDismiss : () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val state = remember { AddConnectionState() } // #9
- var description by remember { mutableStateOf("") }
- var selectedPermissions by remember {
- mutableStateOf(ALL_PERMISSIONS.filter { it.default }.map { it.serverKey }.toSet())
- }
- var neverExpires by remember { mutableStateOf(false) }
-
- 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) }
-
- // ── Budget list state ────────────────────────────────────────────────────
- var nextBudgetId by remember { mutableIntStateOf(0) }
- var budgets by remember { mutableStateOf(emptyList()) }
-
- // ── Date picker dialog ───────────────────────────────────────────────────
- if (showDatePicker) {
+ if (state.showDatePicker) {
+ val initialMillis = state.expiry
+ .atZone(ZoneId.systemDefault())
+ .toInstant()
+ .toEpochMilli()
val datePickerState = rememberDatePickerState(
- initialSelectedDateMillis = expiryCalendar.timeInMillis
+ initialSelectedDateMillis = initialMillis
)
DatePickerDialog(
- onDismissRequest = { showDatePicker = false },
+ onDismissRequest = { state.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))
- }
+ val picked = java.time.Instant
+ .ofEpochMilli(millis)
+ .atZone(ZoneId.systemDefault())
+ .toLocalDate()
+ state.expiry = state.expiry
+ .withYear(picked.year)
+ .withMonth(picked.monthValue)
+ .withDayOfMonth(picked.dayOfMonth)
}
- showDatePicker = false
+ state.showDatePicker = false
}) { Text("OK") }
},
dismissButton = {
- TextButton(onClick = { showDatePicker = false }) { Text("Cancel") }
+ TextButton(onClick = { state.showDatePicker = false }) { Text("Cancel") }
}
) { DatePicker(state = datePickerState) }
}
- // ── Time picker dialog ───────────────────────────────────────────────────
- if (showTimePicker) {
+ if (state.showTimePicker) {
val timePickerState = rememberTimePickerState(
- initialHour = expiryCalendar.get(Calendar.HOUR_OF_DAY),
- initialMinute = expiryCalendar.get(Calendar.MINUTE),
+ initialHour = state.expiry.hour,
+ initialMinute = state.expiry.minute,
is24Hour = true
)
AlertDialog(
- onDismissRequest = { showTimePicker = false },
+ onDismissRequest = { state.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
+ state.expiry = state.expiry
+ .withHour(timePickerState.hour)
+ .withMinute(timePickerState.minute)
+ .withSecond(0)
+ .withNano(0)
+ state.showTimePicker = false
}) { Text("OK") }
},
dismissButton = {
- TextButton(onClick = { showTimePicker = false }) { Text("Cancel") }
+ TextButton(onClick = { state.showTimePicker = false }) { Text("Cancel") }
},
text = { TimePicker(state = timePickerState) }
)
@@ -129,8 +144,8 @@ internal fun NwcAddConnectionSheet(
// ── Description ──────────────────────────────────────────────────
OutlinedTextField(
- value = description,
- onValueChange = { description = it },
+ value = state.description,
+ onValueChange = { state.description = it },
label = { Text("Label (e.g. Amethyst, Bitrefill)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
@@ -144,13 +159,12 @@ internal fun NwcAddConnectionSheet(
Text(text = "Expires", style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.weight(1f))
- if (!neverExpires) {
- val dateLabel = remember(expiryCalendar) {
- SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
- .format(expiryCalendar.time)
+ if (!state.neverExpires) {
+ val dateLabel = remember(state.expiry) {
+ DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.getDefault()).format(state.expiry)
}
SuggestionChip(
- onClick = { showDatePicker = true },
+ onClick = { state.showDatePicker = true },
label = { Text(dateLabel, style = MaterialTheme.typography.bodySmall) },
icon = {
Icon(
@@ -161,15 +175,11 @@ internal fun NwcAddConnectionSheet(
}
)
Spacer(Modifier.weight(1f))
- val timeLabel = remember(expiryCalendar) {
- String.format(
- "%02d:%02d",
- expiryCalendar.get(Calendar.HOUR_OF_DAY),
- expiryCalendar.get(Calendar.MINUTE)
- )
+ val timeLabel = remember(state.expiry) {
+ DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()).format(state.expiry)
}
SuggestionChip(
- onClick = { showTimePicker = true },
+ onClick = { state.showTimePicker = true },
label = { Text(timeLabel, style = MaterialTheme.typography.bodySmall) },
icon = {
Icon(
@@ -183,13 +193,12 @@ internal fun NwcAddConnectionSheet(
Spacer(Modifier.weight(1f))
Checkbox(
- checked = neverExpires,
- onCheckedChange = { neverExpires = it }
+ checked = state.neverExpires,
+ onCheckedChange = { state.neverExpires = it }
)
Text(text = "Never", style = MaterialTheme.typography.bodyMedium)
}
- // ── Budgets ──────────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
@@ -202,8 +211,8 @@ internal fun NwcAddConnectionSheet(
Spacer(Modifier.weight(1f))
TextButton(
onClick = {
- budgets = budgets + BudgetDraft(id = nextBudgetId)
- nextBudgetId++
+ state.budgets += BudgetDraft(id = state.nextBudgetId)
+ state.nextBudgetId++
}
) {
Icon(
@@ -216,16 +225,18 @@ internal fun NwcAddConnectionSheet(
}
}
- budgets.forEach { draft ->
- BudgetRow(
- draft = draft,
- onChange = { updated ->
- budgets = budgets.map { if (it.id == draft.id) updated else it }
- },
- onRemove = {
- budgets = budgets.filter { it.id != draft.id }
- }
- )
+ state.budgets.forEach { draft ->
+ key(draft.id) {
+ BudgetRow(
+ draft = draft,
+ onChange = { updated ->
+ state.budgets = state.budgets.map { if (it.id == draft.id) updated else it }
+ },
+ onRemove = {
+ state.budgets = state.budgets.filter { it.id != draft.id }
+ }
+ )
+ }
}
// ── Permissions ──────────────────────────────────────────────────
@@ -235,39 +246,41 @@ internal fun NwcAddConnectionSheet(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
ALL_PERMISSIONS.forEach { perm ->
- val checked = perm.serverKey in selectedPermissions
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Checkbox(
- checked = checked,
- onCheckedChange = { on ->
- selectedPermissions = if (on)
- selectedPermissions + perm.serverKey
- else
- selectedPermissions - perm.serverKey
- }
- )
- Spacer(Modifier.width(8.dp))
- Text(text = perm.displayLabel, style = MaterialTheme.typography.bodyMedium)
+ key(perm.serverKey) {
+ val checked = perm.serverKey in state.selectedPermissions
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = checked,
+ onCheckedChange = { on ->
+ state.selectedPermissions = if (on)
+ state.selectedPermissions + perm.serverKey
+ else
+ state.selectedPermissions - perm.serverKey
+ }
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(text = perm.displayLabel, style = MaterialTheme.typography.bodyMedium)
+ }
}
}
- // ── Create button ────────────────────────────────────────────────
+ // ── Create button ─────────────────────────────────────────────────
Button(
onClick = {
- val expiresAtSecs = if (neverExpires) 0L
- else expiryCalendar.timeInMillis / 1000L
- val validBudgets = budgets.mapNotNull { it.toNwcNewBudget() }
+ val expiresAtSecs = if (state.neverExpires) 0L
+ else state.expiryEpochSeconds() // #10
+ val validBudgets = state.budgets.mapNotNull { it.toNwcNewBudget() }
onCreate(
- description.trim().ifBlank { "Unnamed" },
- selectedPermissions.toList(),
+ state.description.trim().ifBlank { "Unnamed" },
+ state.selectedPermissions.toList(),
expiresAtSecs,
validBudgets
)
},
- enabled = selectedPermissions.isNotEmpty() && !isCreating,
+ enabled = state.selectedPermissions.isNotEmpty() && !isCreating,
modifier = Modifier.fillMaxWidth()
) {
if (isCreating) {
@@ -284,7 +297,7 @@ internal fun NwcAddConnectionSheet(
}
}
-// ── Budget row ───────────────────────────────────────────────────────────────
+// ── Budget row ────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -294,8 +307,6 @@ private fun BudgetRow(
onRemove : () -> Unit
) {
var dropdownExpanded by remember { mutableStateOf(false) }
-
- // Shared height for both fields so they are always flush
val fieldHeight = 64.dp
Surface(
@@ -310,36 +321,30 @@ private fun BudgetRow(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
- // ── Amount field ─────────────────────────────────────────────────
OutlinedTextField(
- value = draft.amountSats,
- onValueChange = { onChange(draft.copy(amountSats = it.filter(Char::isDigit))) },
- label = { Text("Sats") },
- singleLine = true,
+ value = draft.amountSats,
+ onValueChange = { onChange(draft.copy(amountSats = it.filter(Char::isDigit))) },
+ label = { Text("Sats") },
+ singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
- modifier = Modifier
- .weight(1f)
- .height(fieldHeight)
+ modifier = Modifier.weight(1f).height(fieldHeight)
)
- // ── Refresh-window dropdown ──────────────────────────────────────
ExposedDropdownMenuBox(
expanded = dropdownExpanded,
onExpandedChange = { dropdownExpanded = it },
- modifier = Modifier
- .weight(1f)
- .height(fieldHeight)
+ modifier = Modifier.weight(1f).height(fieldHeight)
) {
OutlinedTextField(
- value = draft.refreshWindow.label,
+ value = draft.refreshWindow.label,
onValueChange = {},
- readOnly = true,
- label = { Text("Resets") },
- trailingIcon = {
+ readOnly = true,
+ label = { Text("Resets") },
+ trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded)
},
- modifier = Modifier
- .menuAnchor()
+ modifier = Modifier
+ .menuAnchor(type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled = true)
.fillMaxWidth()
.height(fieldHeight)
)
@@ -359,7 +364,6 @@ private fun BudgetRow(
}
}
- // ── Remove button ────────────────────────────────────────────────
IconButton(
onClick = onRemove,
modifier = Modifier.size(fieldHeight)
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 52d67c4..80045e3 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
@@ -31,7 +31,6 @@ fun NwcScreen(
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
- // ── Shared refresh lambda (item #13) ──────────────────────────────────────
val refresh = { viewModel.loadConnections(includeExpired = true) }
Scaffold(
@@ -57,7 +56,7 @@ fun NwcScreen(
PullToRefreshBox(
isRefreshing = isRefreshing,
- onRefresh = refresh, // ← was loadConnections(includeExpired = true)
+ onRefresh = refresh,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
@@ -72,7 +71,7 @@ fun NwcScreen(
is NwcUiState.Error -> {
NwcErrorContent(
message = state.message,
- onRetry = refresh, // ← was loadConnections(includeExpired = true)
+ onRetry = refresh,
modifier = Modifier.fillMaxSize()
)
}
@@ -121,9 +120,9 @@ fun NwcScreen(
expiresAt = expiresAt,
budgets = budgets
)
- viewModel.closeAddSheet() // ← was openAddSheet()
+ viewModel.closeAddSheet()
},
- onDismiss = { viewModel.closeAddSheet() } // ← was openAddSheet()
+ onDismiss = { viewModel.closeAddSheet() }
)
}
@@ -148,7 +147,7 @@ fun NwcScreen(
text = { Text("This will permanently revoke this Nostr Wallet Connect key. Any app using it will lose access.") },
confirmButton = {
TextButton(
- onClick = { viewModel.confirmDelete() } // ← was deleteConnection(pubkey) + cancelDelete()
+ onClick = { viewModel.confirmDelete() }
) { Text("Remove", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {