From a121d40bbd699f2878482869e0926f8c5872a7f9 Mon Sep 17 00:00:00 2001 From: rasputin Date: Wed, 10 Jun 2026 18:34:08 +0200 Subject: [PATCH] refactor: add connections sheet --- .idea/dictionaries/project.xml | 2 + .../ui/nwc/NwcAddConnectionSheet.kt | 250 +++++++++--------- .../gudariwallet/ui/nwc/NwcScreen.kt | 11 +- 3 files changed, 134 insertions(+), 129 deletions(-) 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 = {