refactor: extract add connection sheet from nwc

This commit is contained in:
2026-06-10 16:52:37 +02:00
parent af59b385e6
commit 8be9d8ea43
2 changed files with 375 additions and 363 deletions
@@ -0,0 +1,375 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.util.Locale
// ── Add connection bottom sheet ──────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun NwcAddConnectionSheet(
isCreating : Boolean,
onCreate : (
description : String,
permissions : List<String>,
expiresAt : Long,
budgets : List<NwcNewBudget>
) -> Unit,
onDismiss : () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
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) {
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = expiryCalendar.timeInMillis
)
DatePickerDialog(
onDismissRequest = { 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))
}
}
showDatePicker = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) { Text("Cancel") }
}
) { DatePicker(state = datePickerState) }
}
// ── Time picker dialog ───────────────────────────────────────────────────
if (showTimePicker) {
val timePickerState = rememberTimePickerState(
initialHour = expiryCalendar.get(Calendar.HOUR_OF_DAY),
initialMinute = expiryCalendar.get(Calendar.MINUTE),
is24Hour = true
)
AlertDialog(
onDismissRequest = { 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
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showTimePicker = false }) { Text("Cancel") }
},
text = { TimePicker(state = timePickerState) }
)
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "New connection",
style = MaterialTheme.typography.titleMedium
)
// ── Description ──────────────────────────────────────────────────
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Label (e.g. Amethyst, Bitrefill)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// ── Expiry ───────────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
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)
}
SuggestionChip(
onClick = { showDatePicker = true },
label = { Text(dateLabel, style = MaterialTheme.typography.bodySmall) },
icon = {
Icon(
imageVector = Icons.Default.CalendarToday,
contentDescription = "Pick date",
modifier = Modifier.size(16.dp)
)
}
)
Spacer(Modifier.weight(1f))
val timeLabel = remember(expiryCalendar) {
String.format(
"%02d:%02d",
expiryCalendar.get(Calendar.HOUR_OF_DAY),
expiryCalendar.get(Calendar.MINUTE)
)
}
SuggestionChip(
onClick = { showTimePicker = true },
label = { Text(timeLabel, style = MaterialTheme.typography.bodySmall) },
icon = {
Icon(
imageVector = Icons.Default.Schedule,
contentDescription = "Pick time",
modifier = Modifier.size(16.dp)
)
}
)
}
Spacer(Modifier.weight(1f))
Checkbox(
checked = neverExpires,
onCheckedChange = { neverExpires = it }
)
Text(text = "Never", style = MaterialTheme.typography.bodyMedium)
}
// ── Budgets ──────────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Budgets",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.weight(1f))
TextButton(
onClick = {
budgets = budgets + BudgetDraft(id = nextBudgetId)
nextBudgetId++
}
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add budget",
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text("Add budget")
}
}
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 }
}
)
}
// ── Permissions ──────────────────────────────────────────────────
Text(
text = "Permissions",
style = MaterialTheme.typography.labelLarge,
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)
}
}
// ── Create button ────────────────────────────────────────────────
Button(
onClick = {
val expiresAtSecs = if (neverExpires) 0L
else expiryCalendar.timeInMillis / 1000L
val validBudgets = budgets.mapNotNull { it.toNwcNewBudget() }
onCreate(
description.trim().ifBlank { "Unnamed" },
selectedPermissions.toList(),
expiresAtSecs,
validBudgets
)
},
enabled = selectedPermissions.isNotEmpty() && !isCreating,
modifier = Modifier.fillMaxWidth()
) {
if (isCreating) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(Modifier.width(8.dp))
}
Text(if (isCreating) "Creating…" else "Create connection")
}
}
}
}
// ── Budget row ───────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BudgetRow(
draft : BudgetDraft,
onChange : (BudgetDraft) -> Unit,
onRemove : () -> Unit
) {
var dropdownExpanded by remember { mutableStateOf(false) }
// Shared height for both fields so they are always flush
val fieldHeight = 64.dp
Surface(
shape = MaterialTheme.shapes.medium,
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
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,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier
.weight(1f)
.height(fieldHeight)
)
// ── Refresh-window dropdown ──────────────────────────────────────
ExposedDropdownMenuBox(
expanded = dropdownExpanded,
onExpandedChange = { dropdownExpanded = it },
modifier = Modifier
.weight(1f)
.height(fieldHeight)
) {
OutlinedTextField(
value = draft.refreshWindow.label,
onValueChange = {},
readOnly = true,
label = { Text("Resets") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded)
},
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
.height(fieldHeight)
)
ExposedDropdownMenu(
expanded = dropdownExpanded,
onDismissRequest = { dropdownExpanded = false }
) {
BudgetRefreshWindow.entries.forEach { window ->
DropdownMenuItem(
text = { Text(window.label) },
onClick = {
onChange(draft.copy(refreshWindow = window))
dropdownExpanded = false
}
)
}
}
}
// ── Remove button ────────────────────────────────────────────────
IconButton(
onClick = onRemove,
modifier = Modifier.size(fieldHeight)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove budget",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
@@ -3,27 +3,16 @@ package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.api.NwcNewBudget
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
// ── Screen ────────────────────────────────────────────────────────────────────
@@ -161,356 +150,4 @@ fun NwcScreen(
}
)
}
}
// ── Add connection bottom sheet ───────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NwcAddConnectionSheet(
isCreating : Boolean,
onCreate : (
description : String,
permissions : List<String>,
expiresAt : Long,
budgets : List<NwcNewBudget> // ← NEW
) -> Unit,
onDismiss : () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
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) {
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = expiryCalendar.timeInMillis
)
DatePickerDialog(
onDismissRequest = { 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))
}
}
showDatePicker = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) { Text("Cancel") }
}
) { DatePicker(state = datePickerState) }
}
// ── Time picker dialog ─────────────────────────────────────────────────────
if (showTimePicker) {
val timePickerState = rememberTimePickerState(
initialHour = expiryCalendar.get(Calendar.HOUR_OF_DAY),
initialMinute = expiryCalendar.get(Calendar.MINUTE),
is24Hour = true
)
AlertDialog(
onDismissRequest = { 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
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showTimePicker = false }) { Text("Cancel") }
},
text = { TimePicker(state = timePickerState) }
)
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "New connection",
style = MaterialTheme.typography.titleMedium
)
// ── Description ───────────────────────────────────────────────────
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Label (e.g. Amethyst, Bitrefill)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// ── Expiry ────────────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
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)
}
SuggestionChip(
onClick = { showDatePicker = true },
label = { Text(dateLabel, style = MaterialTheme.typography.bodySmall) },
icon = {
Icon(
imageVector = Icons.Default.CalendarToday,
contentDescription = "Pick date",
modifier = Modifier.size(16.dp)
)
}
)
Spacer(Modifier.weight(1f))
val timeLabel = remember(expiryCalendar) {
String.format(
"%02d:%02d",
expiryCalendar.get(Calendar.HOUR_OF_DAY),
expiryCalendar.get(Calendar.MINUTE)
)
}
SuggestionChip(
onClick = { showTimePicker = true },
label = { Text(timeLabel, style = MaterialTheme.typography.bodySmall) },
icon = {
Icon(
imageVector = Icons.Default.Schedule,
contentDescription = "Pick time",
modifier = Modifier.size(16.dp)
)
}
)
}
Spacer(Modifier.weight(1f))
Checkbox(
checked = neverExpires,
onCheckedChange = { neverExpires = it }
)
Text(text = "Never", style = MaterialTheme.typography.bodyMedium)
}
// ── Budgets ───────────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Budgets",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.weight(1f))
TextButton(
onClick = {
budgets = budgets + BudgetDraft(id = nextBudgetId)
nextBudgetId++
}
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add budget",
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text("Add budget")
}
}
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 }
}
)
}
// ── Permissions ───────────────────────────────────────────────────
Text(
text = "Permissions",
style = MaterialTheme.typography.labelLarge,
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)
}
}
// ── Create button ─────────────────────────────────────────────────
Button(
onClick = {
val expiresAtSecs = if (neverExpires) 0L
else expiryCalendar.timeInMillis / 1000L
val validBudgets = budgets.mapNotNull { it.toNwcNewBudget() }
onCreate(
description.trim().ifBlank { "Unnamed" },
selectedPermissions.toList(),
expiresAtSecs,
validBudgets
)
},
enabled = selectedPermissions.isNotEmpty() && !isCreating,
modifier = Modifier.fillMaxWidth()
) {
if (isCreating) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(Modifier.width(8.dp))
}
Text(if (isCreating) "Creating…" else "Create connection")
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BudgetRow(
draft : BudgetDraft,
onChange : (BudgetDraft) -> Unit,
onRemove : () -> Unit
) {
var dropdownExpanded by remember { mutableStateOf(false) }
// Shared height for both fields so they are always flush
val fieldHeight = 64.dp
Surface(
shape = MaterialTheme.shapes.medium,
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
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,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier
.weight(1f)
.height(fieldHeight)
)
// ── Refresh-window dropdown ───────────────────────────────────────
ExposedDropdownMenuBox(
expanded = dropdownExpanded,
onExpandedChange = { dropdownExpanded = it },
modifier = Modifier
.weight(1f)
.height(fieldHeight)
) {
OutlinedTextField(
value = draft.refreshWindow.label,
onValueChange = {},
readOnly = true,
label = { Text("Resets") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded)
},
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
.height(fieldHeight)
)
ExposedDropdownMenu(
expanded = dropdownExpanded,
onDismissRequest = { dropdownExpanded = false }
) {
BudgetRefreshWindow.entries.forEach { window ->
DropdownMenuItem(
text = { Text(window.label) },
onClick = {
onChange(draft.copy(refreshWindow = window))
dropdownExpanded = false
}
)
}
}
}
// ── Remove button ─────────────────────────────────────────────────
IconButton(
onClick = onRemove,
modifier = Modifier.size(fieldHeight) // matches field height
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove budget",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}