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