From 8be9d8ea431e605c607b768a235dc8104aa42422 Mon Sep 17 00:00:00 2001 From: rasputin Date: Wed, 10 Jun 2026 16:52:37 +0200 Subject: [PATCH] refactor: extract add connection sheet from nwc --- .../ui/nwc/NwcAddConnectionSheet.kt | 375 ++++++++++++++++++ .../gudariwallet/ui/nwc/NwcScreen.kt | 363 ----------------- 2 files changed, 375 insertions(+), 363 deletions(-) create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcAddConnectionSheet.kt 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 new file mode 100644 index 0000000..6d8c970 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcAddConnectionSheet.kt @@ -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, + expiresAt : Long, + budgets : List + ) -> 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()) } + + // ── 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 + ) + } + } + } +} 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 905ef33..11dc38b 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 @@ -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, - expiresAt : Long, - budgets : List // ← 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()) } - - // ── 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 - ) - } - } - } } \ No newline at end of file