refactor: extract add connection sheet from nwc
This commit is contained in:
@@ -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.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Add
|
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.*
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.bitcointxoko.gudariwallet.api.NwcNewBudget
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Calendar
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
// ── Screen ────────────────────────────────────────────────────────────────────
|
// ── Screen ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -162,355 +151,3 @@ 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user