refactor: add connections sheet
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user