* 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:
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) }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user