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