refactor: add connections sheet

This commit is contained in:
2026-06-10 18:34:08 +02:00
parent e5f8156ecf
commit a121d40bbd
3 changed files with 134 additions and 129 deletions
+2
View File
@@ -3,6 +3,7 @@
<words>
<w>Aead</w>
<w>Nbits</w>
<w>Nostr</w>
<w>Tink</w>
<w>amboss</w>
<w>hkdf</w>
@@ -13,6 +14,7 @@
<w>msat</w>
<w>satoshis</w>
<w>sats</w>
<w>snackbar</w>
</words>
</dictionary>
</component>
@@ -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<BudgetDraft>())
// 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<BudgetDraft>()) }
// ── 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)
@@ -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 = {