feat(#371): connection profiles full management (add, edit, delete) (#393)

* feat(#371): add full connection profiles CRUD (add/edit/delete)

* fix(#371): add missing TextButton import

* fix(#371): move Save button to header so it's visible on all settings tabs

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-26 08:07:30 +03:00
1 parent 8d3d8a11e8
commit 2a9d4fa5be
3 files changed
+372 -70

No files matched your search

@@ -20,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Visibility
@@ -48,6 +49,7 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -95,6 +97,11 @@ fun SettingsScreen(
HermesScaffold(
title = { Text(stringResource(R.string.screen_settings)) },
navigationIcon = NavIcon.Back(onBack),
actions = {
TextButton(onClick = viewModel::save) {
Text(stringResource(R.string.action_save))
}
},
) {
Column(
modifier =
@@ -143,10 +150,9 @@ fun SettingsScreen(
TestResultCard(testResult = state.testResult)
SaveIndicator(isSaved = state.isSaved)
SettingsActionButtons(
TestConnectionButton(
isTesting = state.isTesting,
onTest = viewModel::testConnection,
onSave = viewModel::save,
)
Spacer(modifier = Modifier.height(16.dp))
@@ -313,6 +319,7 @@ private fun ConnectionSection(
onPasswordVisibilityToggle: () -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_connection)) {
// ── Profile selector ─────────────────────────────────────────
if (state.profiles.isNotEmpty()) {
var profilesExpanded by remember { mutableStateOf(false) }
Text(
@@ -348,30 +355,7 @@ private fun ConnectionSection(
)
state.profiles.forEach { profile ->
DropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(profile.name)
IconButton(
onClick = {
viewModel.deleteProfile(profile.id)
profilesExpanded = false
},
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription =
stringResource(
R.string.content_desc_delete_profile,
),
tint = MaterialTheme.colorScheme.error,
)
}
}
},
text = { Text(profile.name) },
onClick = {
viewModel.selectProfile(profile.id)
profilesExpanded = false
@@ -381,26 +365,92 @@ private fun ConnectionSection(
}
}
if (state.selectedProfileId != null) {
OutlinedTextField(
value = state.renameProfileName,
onValueChange = viewModel::onRenameProfileNameChange,
label = { Text(stringResource(R.string.settings_action_rename_profile)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
Button(
onClick = viewModel::renameProfile,
modifier = Modifier.padding(end = 4.dp),
) {
Text(stringResource(R.string.action_rename))
Spacer(modifier = Modifier.height(4.dp))
// ── Saved profiles list ──────────────────────────────────
Text(
text = stringResource(R.string.settings_saved_profiles, state.profiles.size),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
state.profiles.forEach { profile ->
val isActive = profile.id == state.selectedProfileId
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
colors =
CardDefaults.cardColors(
containerColor =
if (isActive) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
},
),
elevation = CardDefaults.cardElevation(0.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = profile.name,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
)
Text(
text = "${profile.host}:${profile.port}",
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
},
)
Spacer(modifier = Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
IconButton(
onClick = { viewModel.openEditProfile(profile.id) },
) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = stringResource(R.string.settings_action_edit_profile),
modifier = Modifier.size(18.dp),
)
}
IconButton(
onClick = { viewModel.requestDeleteProfile(profile.id) },
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.content_desc_delete_profile),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp),
)
}
}
}
}
}
}
// ── Add profile button ───────────────────────────────────────
OutlinedButton(
onClick = viewModel::openAddProfile,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.size(4.dp))
Text(stringResource(R.string.settings_action_add_profile))
}
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.host,
onValueChange = viewModel::onHostChange,
@@ -486,6 +536,136 @@ private fun ConnectionSection(
Text(stringResource(R.string.settings_action_clear_token))
}
}
// ── Dialogs ──────────────────────────────────────────────────────────
if (state.showProfileDialog) {
ProfileEditorDialog(
isEditing = state.editingProfileId != null,
name = state.dialogProfileName,
host = state.dialogProfileHost,
port = state.dialogProfilePort,
token = state.dialogProfileToken,
onNameChange = viewModel::onDialogProfileNameChange,
onHostChange = viewModel::onDialogProfileHostChange,
onPortChange = viewModel::onDialogProfilePortChange,
onTokenChange = viewModel::onDialogProfileTokenChange,
onSave = viewModel::saveProfileFromDialog,
onDismiss = viewModel::closeProfileDialog,
)
}
if (state.showDeleteConfirm) {
DeleteProfileConfirmDialog(
profileName = state.profileToDeleteName,
onConfirm = viewModel::confirmDeleteProfile,
onDismiss = viewModel::cancelDeleteProfile,
)
}
}
@Composable
private fun ProfileEditorDialog(
isEditing: Boolean,
name: String,
host: String,
port: String,
token: String,
onNameChange: (String) -> Unit,
onHostChange: (String) -> Unit,
onPortChange: (String) -> Unit,
onTokenChange: (String) -> Unit,
onSave: () -> Unit,
onDismiss: () -> Unit,
) {
val title =
if (isEditing) {
stringResource(R.string.settings_title_edit_profile)
} else {
stringResource(R.string.settings_title_add_profile)
}
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text(stringResource(R.string.settings_field_profile_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = host,
onValueChange = onHostChange,
label = { Text(stringResource(R.string.settings_field_host)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = port,
onValueChange = onPortChange,
label = { Text(stringResource(R.string.settings_field_port)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = token,
onValueChange = onTokenChange,
label = { Text(stringResource(R.string.settings_field_token)) },
singleLine = true,
visualTransformation =
if (token.isNotEmpty()) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
},
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Button(onClick = onSave, enabled = name.isNotBlank() && host.isNotBlank()) {
Text(stringResource(R.string.action_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@Composable
private fun DeleteProfileConfirmDialog(
profileName: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_delete_confirm_title)) },
text = {
Text(stringResource(R.string.settings_delete_confirm_message, profileName))
},
confirmButton = {
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.action_delete))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@Composable
@@ -536,39 +716,22 @@ private fun SaveIndicator(isSaved: Boolean) {
}
@Composable
private fun SettingsActionButtons(
private fun TestConnectionButton(
isTesting: Boolean,
onTest: () -> Unit,
onSave: () -> Unit,
) {
Row(
OutlinedButton(
onClick = onTest,
enabled = !isTesting,
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = onTest,
enabled = !isTesting,
modifier = Modifier.weight(1f),
) {
if (isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else {
Text(stringResource(R.string.settings_action_test_connection))
}
}
Button(
onClick = onSave,
modifier = Modifier.weight(1f),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(stringResource(R.string.action_save))
if (isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else {
Text(stringResource(R.string.settings_action_test_connection))
}
}
}
@@ -37,6 +37,17 @@ data class SettingsUiState(
val selectedProfileId: String? = null,
val renameProfileName: String = "",
val bottomNavDisplayMode: BottomNavDisplayMode = BottomNavDisplayMode.ICON_AND_TEXT,
// Profile add/edit dialog
val showProfileDialog: Boolean = false,
val editingProfileId: String? = null,
val dialogProfileName: String = "",
val dialogProfileHost: String = "",
val dialogProfilePort: String = "",
val dialogProfileToken: String = "",
// Delete confirmation
val showDeleteConfirm: Boolean = false,
val profileToDeleteId: String? = null,
val profileToDeleteName: String = "",
)
class SettingsViewModel(
@@ -120,6 +131,125 @@ class SettingsViewModel(
ApiClient.rebuild()
}
// ── Profile add/edit dialog ──────────────────────────────────────────
fun openAddProfile() {
_uiState.update {
it.copy(
showProfileDialog = true,
editingProfileId = null,
dialogProfileName = "",
dialogProfileHost = "127.0.0.1",
dialogProfilePort = "9119",
dialogProfileToken = "",
)
}
}
fun openEditProfile(profileId: String) {
val profile = AuthManager.getConnectionProfiles().firstOrNull { it.id == profileId } ?: return
val token = AuthManager.getProfileToken(profileId) ?: ""
_uiState.update {
it.copy(
showProfileDialog = true,
editingProfileId = profileId,
dialogProfileName = profile.name,
dialogProfileHost = profile.host,
dialogProfilePort = profile.port.toString(),
dialogProfileToken = token,
)
}
}
fun closeProfileDialog() {
_uiState.update {
it.copy(showProfileDialog = false, editingProfileId = null)
}
}
fun onDialogProfileNameChange(value: String) {
_uiState.update { it.copy(dialogProfileName = value) }
}
fun onDialogProfileHostChange(value: String) {
_uiState.update { it.copy(dialogProfileHost = value.trim()) }
}
fun onDialogProfilePortChange(value: String) {
_uiState.update { it.copy(dialogProfilePort = value.filter { c -> c.isDigit() }) }
}
fun onDialogProfileTokenChange(value: String) {
_uiState.update { it.copy(dialogProfileToken = value.trim()) }
}
fun saveProfileFromDialog() {
val state = _uiState.value
val name = state.dialogProfileName.trim()
val host = state.dialogProfileHost.trim()
val port = state.dialogProfilePort.toIntOrNull() ?: return
val token = state.dialogProfileToken
if (name.isBlank() || host.isBlank()) return
val profiles = AuthManager.getConnectionProfiles().toMutableList()
val editingId = state.editingProfileId
if (editingId != null) {
// Update existing profile
val index = profiles.indexOfFirst { it.id == editingId }
if (index == -1) return
val oldToken = AuthManager.getProfileToken(editingId)
profiles[index] = profiles[index].copy(name = name, host = host, port = port)
AuthManager.saveConnectionProfiles(profiles)
if (token != oldToken) {
AuthManager.setProfileToken(editingId, token)
}
} else {
// Add new profile
val newProfile =
com.m57.hermescontrol.data.model.ConnectionProfile(
name = name,
host = host,
port = port,
)
profiles.add(newProfile)
AuthManager.saveConnectionProfiles(profiles)
AuthManager.setProfileToken(newProfile.id, token)
}
closeProfileDialog()
viewModelScope.launch(ioDispatcher) { loadSettings() }
ApiClient.rebuild()
}
// ── Delete confirmation ──────────────────────────────────────────────
fun requestDeleteProfile(profileId: String) {
val profile = AuthManager.getConnectionProfiles().firstOrNull { it.id == profileId }
_uiState.update {
it.copy(
showDeleteConfirm = true,
profileToDeleteId = profileId,
profileToDeleteName = profile?.name ?: "",
)
}
}
fun confirmDeleteProfile() {
val profileId = _uiState.value.profileToDeleteId ?: return
deleteProfile(profileId)
_uiState.update {
it.copy(showDeleteConfirm = false, profileToDeleteId = null, profileToDeleteName = "")
}
}
fun cancelDeleteProfile() {
_uiState.update {
it.copy(showDeleteConfirm = false, profileToDeleteId = null, profileToDeleteName = "")
}
}
fun onHostChange(value: String) {
_uiState.update { it.copy(host = value.trim(), isSaved = false) }
}