diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt
index 39e2a2cf..c71ba7b0 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt
@@ -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))
}
}
}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt
index 88234ede..8e8b4570 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt
@@ -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) }
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 60b5e38c..30ad22ba 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -148,6 +148,13 @@
Commit
Rename
Save
+ Add Profile
+ Edit Profile
+ Add Connection Profile
+ Edit Connection Profile
+ Profile Name
+ Delete Profile?
+ Are you sure you want to delete \"%1$s\"? This can\'t be undone.
Delete Profile
Hide token
Show token
@@ -369,6 +376,8 @@
Not set
+ Delete
+ Saved Profiles (%1$d)
Logout