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 0843f9b0..c3653be7 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 @@ -39,12 +39,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -68,6 +70,13 @@ import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreset import com.m57.hermescontrol.ui.common.HermesScaffold +enum class SettingsTab { + CONNECTION, + APPEARANCE, + BEHAVIOR, + ABOUT, +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( @@ -77,6 +86,7 @@ fun SettingsScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() var passwordVisible by remember { mutableStateOf(false) } + var selectedTab by remember { mutableStateOf(SettingsTab.CONNECTION) } BackHandler(onBack = onBack) @@ -88,308 +98,405 @@ fun SettingsScreen( Column( modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .fillMaxSize(), ) { - // Connection section - SectionCard(title = stringResource(R.string.settings_sec_connection)) { - // Connection Profiles management in Settings - if (state.profiles.isNotEmpty()) { - var profilesExpanded by remember { mutableStateOf(false) } - Text( - text = stringResource(R.string.settings_item_profile), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Box(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - OutlinedButton( - onClick = { profilesExpanded = true }, - modifier = Modifier.fillMaxWidth(), - ) { - val activeProfile = state.profiles.firstOrNull { it.id == state.selectedProfileId } - Text(text = activeProfile?.name ?: stringResource(R.string.settings_profile_default)) - } - DropdownMenu( - expanded = profilesExpanded, - onDismissRequest = { profilesExpanded = false }, - modifier = Modifier.fillMaxWidth(0.85f), - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.settings_profile_none)) }, - onClick = { - viewModel.selectProfile(null) - profilesExpanded = false + ScrollableTabRow( + selectedTabIndex = selectedTab.ordinal, + modifier = Modifier.fillMaxWidth(), + edgePadding = 0.dp, + ) { + SettingsTab.entries.forEach { tab -> + Tab( + selected = selectedTab == tab, + onClick = { selectedTab = tab }, + text = { + Text( + when (tab) { + SettingsTab.CONNECTION -> stringResource(R.string.settings_tab_connection) + SettingsTab.APPEARANCE -> stringResource(R.string.settings_tab_appearance) + SettingsTab.BEHAVIOR -> stringResource(R.string.settings_tab_behavior) + SettingsTab.ABOUT -> stringResource(R.string.settings_tab_about) }, ) - state.profiles.forEach { profile -> - DropdownMenuItem( - text = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(profile.name) - IconButton( + }, + ) + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + when (selectedTab) { + SettingsTab.CONNECTION -> { + // Connection section + SectionCard(title = stringResource(R.string.settings_sec_connection)) { + // Connection Profiles management in Settings + if (state.profiles.isNotEmpty()) { + var profilesExpanded by remember { mutableStateOf(false) } + Text( + text = stringResource(R.string.settings_item_profile), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + OutlinedButton( + onClick = { profilesExpanded = true }, + modifier = Modifier.fillMaxWidth(), + ) { + val activeProfile = + state.profiles.firstOrNull { it.id == state.selectedProfileId } + Text( + text = + activeProfile?.name ?: stringResource( + R.string.settings_profile_default, + ), + ) + } + DropdownMenu( + expanded = profilesExpanded, + onDismissRequest = { profilesExpanded = false }, + modifier = Modifier.fillMaxWidth(0.85f), + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.settings_profile_none)) }, + onClick = { + viewModel.selectProfile(null) + profilesExpanded = false + }, + ) + 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, + ) + } + } + }, onClick = { - viewModel.deleteProfile(profile.id) + viewModel.selectProfile(profile.id) profilesExpanded = false }, - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = - stringResource( - R.string.content_desc_delete_profile, - ), - tint = MaterialTheme.colorScheme.error, - ) - } + ) } + } + } + + 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(12.dp)) + } + } + + OutlinedTextField( + value = state.host, + onValueChange = viewModel::onHostChange, + label = { Text(stringResource(R.string.settings_field_host)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + cursorColor = MaterialTheme.colorScheme.primary, + ), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.port, + onValueChange = viewModel::onPortChange, + label = { Text(stringResource(R.string.settings_field_port)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + cursorColor = MaterialTheme.colorScheme.primary, + ), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.token, + onValueChange = viewModel::onTokenChange, + label = { Text(stringResource(R.string.settings_field_token)) }, + singleLine = true, + visualTransformation = + if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() }, - onClick = { - viewModel.selectProfile(profile.id) - profilesExpanded = false - }, + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = + if (passwordVisible) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + }, + contentDescription = + if (passwordVisible) { + stringResource(R.string.content_desc_hide_token) + } else { + stringResource(R.string.content_desc_show_token) + }, + ) + } + }, + modifier = Modifier.fillMaxWidth(), + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + cursorColor = MaterialTheme.colorScheme.primary, + ), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // B7 (Jun 18 2026): one-tap escape hatch for when the stored + // token is corrupted / rejected. Clears the token field and + // persists the empty value immediately so a stress-free + // re-pair is possible without manually selecting-and-deleting + // the masked password field character by character. + OutlinedButton( + onClick = { + viewModel.onTokenChange("") + viewModel.save() + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.settings_action_clear_token)) + } + } + + // Test result + AnimatedVisibility( + visible = state.testResult != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + state.testResult?.let { result -> + Card( + colors = + CardDefaults.cardColors( + containerColor = + if (result.startsWith("✅")) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.errorContainer + }, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = result, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + // Save indicator + AnimatedVisibility( + visible = state.isSaved, + enter = fadeIn(), + exit = fadeOut(), + ) { + Text( + text = stringResource(R.string.settings_save_success), + style = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.primary, + ), + ) + } + + // Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = viewModel::testConnection, + enabled = !state.isTesting, + modifier = Modifier.weight(1f), + ) { + if (state.isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else { + Text(stringResource(R.string.settings_action_test_connection)) + } + } + + Button( + onClick = viewModel::save, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(stringResource(R.string.action_save)) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + SettingsTab.BEHAVIOR -> { + // Behavior section + SectionCard(title = stringResource(R.string.settings_sec_behavior)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = stringResource(R.string.settings_item_auto_reconnect), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.settings_desc_auto_reconnect), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Switch( + checked = state.autoReconnect, + onCheckedChange = viewModel::onAutoReconnectChange, + colors = + SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, + ), ) } } } - - 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(12.dp)) - } - } - - OutlinedTextField( - value = state.host, - onValueChange = viewModel::onHostChange, - label = { Text(stringResource(R.string.settings_field_host)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - cursorColor = MaterialTheme.colorScheme.primary, - ), - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = state.port, - onValueChange = viewModel::onPortChange, - label = { Text(stringResource(R.string.settings_field_port)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - cursorColor = MaterialTheme.colorScheme.primary, - ), - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = state.token, - onValueChange = viewModel::onTokenChange, - label = { Text(stringResource(R.string.settings_field_token)) }, - singleLine = true, - visualTransformation = - if (passwordVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon( - imageVector = - if (passwordVisible) { - Icons.Filled.Visibility - } else { - Icons.Filled.VisibilityOff - }, - contentDescription = - if (passwordVisible) { - stringResource(R.string.content_desc_hide_token) - } else { - stringResource(R.string.content_desc_show_token) - }, - ) - } - }, - modifier = Modifier.fillMaxWidth(), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - cursorColor = MaterialTheme.colorScheme.primary, - ), - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // B7 (Jun 18 2026): one-tap escape hatch for when the stored - // token is corrupted / rejected. Clears the token field and - // persists the empty value immediately so a stress-free - // re-pair is possible without manually selecting-and-deleting - // the masked password field character by character. - OutlinedButton( - onClick = { - viewModel.onTokenChange("") - viewModel.save() - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.settings_action_clear_token)) - } - } - - // Behavior section - SectionCard(title = stringResource(R.string.settings_sec_behavior)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text( - text = stringResource(R.string.settings_item_auto_reconnect), - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = stringResource(R.string.settings_desc_auto_reconnect), - style = - MaterialTheme.typography.bodySmall.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) - } - Switch( - checked = state.autoReconnect, - onCheckedChange = viewModel::onAutoReconnectChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), - ) - } - } - - // Appearance section - SectionCard(title = stringResource(R.string.settings_sec_appearance)) { - Text( - text = stringResource(R.string.settings_item_theme), - style = MaterialTheme.typography.bodyLarge, - ) - Spacer(modifier = Modifier.height(8.dp)) - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - ThemePreference.entries.forEachIndexed { index, pref -> - SegmentedButton( - selected = state.themePreference == pref, - onClick = { viewModel.onThemeChange(pref) }, - shape = - SegmentedButtonDefaults.itemShape( - index = index, - count = ThemePreference.entries.size, - ), - ) { + SettingsTab.APPEARANCE -> { + // Appearance section + SectionCard(title = stringResource(R.string.settings_sec_appearance)) { Text( - when (pref) { - ThemePreference.SYSTEM -> stringResource(R.string.theme_system) - ThemePreference.LIGHT -> stringResource(R.string.theme_light) - ThemePreference.DARK -> stringResource(R.string.theme_dark) - }, + text = stringResource(R.string.settings_item_theme), + style = MaterialTheme.typography.bodyLarge, ) - } - } - } + Spacer(modifier = Modifier.height(8.dp)) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + ThemePreference.entries.forEachIndexed { index, pref -> + SegmentedButton( + selected = state.themePreference == pref, + onClick = { viewModel.onThemeChange(pref) }, + shape = + SegmentedButtonDefaults.itemShape( + index = index, + count = ThemePreference.entries.size, + ), + ) { + Text( + when (pref) { + ThemePreference.SYSTEM -> stringResource(R.string.theme_system) + ThemePreference.LIGHT -> stringResource(R.string.theme_light) + ThemePreference.DARK -> stringResource(R.string.theme_dark) + }, + ) + } + } + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.settings_item_use_dynamic_colors), - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = stringResource(R.string.settings_desc_use_dynamic_colors), - style = - MaterialTheme.typography.bodySmall.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) - } - Switch( - checked = state.useDynamicColors, - onCheckedChange = viewModel::onUseDynamicColorsChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = stringResource(R.string.settings_item_theme_preset), - style = MaterialTheme.typography.bodyLarge, - ) - Spacer(modifier = Modifier.height(8.dp)) - - var presetsExpanded by remember { mutableStateOf(false) } - Box(modifier = Modifier.fillMaxWidth()) { - OutlinedButton( - onClick = { presetsExpanded = true }, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - when (state.themePreset) { - ThemePreset.DEFAULT -> stringResource(R.string.theme_preset_default) - ThemePreset.MONOCHROME -> stringResource(R.string.theme_preset_monochrome) - ThemePreset.GRUVBOX -> stringResource(R.string.theme_preset_gruvbox) - ThemePreset.CATPPUCCIN -> stringResource(R.string.theme_preset_catppuccin) - ThemePreset.AMOLED -> stringResource(R.string.theme_preset_amoled) - }, - ) - } - DropdownMenu( - expanded = presetsExpanded, - onDismissRequest = { presetsExpanded = false }, - modifier = Modifier.fillMaxWidth(0.85f), - ) { - ThemePreset.entries.forEach { preset -> - DropdownMenuItem( - text = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { Text( - when (preset) { + text = stringResource(R.string.settings_item_use_dynamic_colors), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.settings_desc_use_dynamic_colors), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Switch( + checked = state.useDynamicColors, + onCheckedChange = viewModel::onUseDynamicColorsChange, + colors = + SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.settings_item_theme_preset), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + + var presetsExpanded by remember { mutableStateOf(false) } + Box(modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { presetsExpanded = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + when (state.themePreset) { ThemePreset.DEFAULT -> stringResource(R.string.theme_preset_default) ThemePreset.MONOCHROME -> stringResource(R.string.theme_preset_monochrome) ThemePreset.GRUVBOX -> stringResource(R.string.theme_preset_gruvbox) @@ -397,346 +504,316 @@ fun SettingsScreen( ThemePreset.AMOLED -> stringResource(R.string.theme_preset_amoled) }, ) - }, - onClick = { - viewModel.onThemePresetChange(preset) - presetsExpanded = false - }, - ) - } - } - } - } - - // Chat section - SectionCard(title = stringResource(R.string.settings_sec_chat)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text( - text = stringResource(R.string.settings_item_typing_effect), - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = stringResource(R.string.settings_desc_typing_effect), - style = - MaterialTheme.typography.bodySmall.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) - } - Switch( - checked = state.typingEffectEnabled, - onCheckedChange = viewModel::onTypingEffectEnabledChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), - ) - } - - // Delay slider — only visible when effect is enabled - AnimatedVisibility(visible = state.typingEffectEnabled) { - Column(modifier = Modifier.padding(top = 12.dp)) { - Text( - text = stringResource(R.string.settings_item_typing_delay, state.typingEffectDelayMs), - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(modifier = Modifier.height(4.dp)) - Slider( - value = state.typingEffectDelayMs.toFloat(), - onValueChange = { viewModel.onTypingEffectDelayMsChange(it.toInt()) }, - valueRange = 10f..100f, - steps = 8, // 10, 20, 30, 40, 50, 60, 70, 80, 90, 100 - modifier = Modifier.fillMaxWidth(), - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(R.string.settings_delay_10ms), - style = - MaterialTheme.typography.labelSmall.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) - Text( - text = stringResource(R.string.settings_delay_100ms), - style = - MaterialTheme.typography.labelSmall.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) - } - } - } - } - - // Navigation Bar section - SectionCard(title = stringResource(R.string.settings_sec_nav_bar)) { - Text( - text = stringResource(R.string.settings_item_bottom_nav_display_mode), - style = MaterialTheme.typography.bodyLarge, - ) - Spacer(modifier = Modifier.height(8.dp)) - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - BottomNavDisplayMode.entries.forEachIndexed { index, mode -> - SegmentedButton( - selected = state.bottomNavDisplayMode == mode, - onClick = { viewModel.onBottomNavDisplayModeChange(mode) }, - shape = - SegmentedButtonDefaults.itemShape( - index = index, - count = BottomNavDisplayMode.entries.size, - ), - ) { - Text( - when (mode) { - BottomNavDisplayMode.ICON_AND_TEXT -> { - stringResource( - R.string.settings_display_mode_icon_and_text, + } + DropdownMenu( + expanded = presetsExpanded, + onDismissRequest = { presetsExpanded = false }, + modifier = Modifier.fillMaxWidth(0.85f), + ) { + ThemePreset.entries.forEach { preset -> + DropdownMenuItem( + text = { + Text( + when (preset) { + ThemePreset.DEFAULT -> + stringResource( + R.string.theme_preset_default, + ) + ThemePreset.MONOCHROME -> + stringResource( + R.string.theme_preset_monochrome, + ) + ThemePreset.GRUVBOX -> + stringResource( + R.string.theme_preset_gruvbox, + ) + ThemePreset.CATPPUCCIN -> + stringResource( + R.string.theme_preset_catppuccin, + ) + ThemePreset.AMOLED -> + stringResource( + R.string.theme_preset_amoled, + ) + }, + ) + }, + onClick = { + viewModel.onThemePresetChange(preset) + presetsExpanded = false + }, ) } - - BottomNavDisplayMode.ICON_ONLY -> { - stringResource( - R.string.settings_display_mode_icon_only, - ) - } - - BottomNavDisplayMode.TEXT_ONLY -> { - stringResource( - R.string.settings_display_mode_text_only, - ) - } - }, - ) - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = stringResource(R.string.settings_item_nav_items, state.selectedNavItems.size), - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(modifier = Modifier.height(8.dp)) - - state.selectedNavItems.forEachIndexed { index, name -> - val labelRes = viewModel.availableNavItems.firstOrNull { it.first == name }?.second - val label = if (labelRes != null) stringResource(labelRes) else name - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - ) - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - IconButton( - onClick = { - val list = state.selectedNavItems.toMutableList() - val temp = list[index] - list[index] = list[index - 1] - list[index - 1] = temp - viewModel.reorderNavItems(list) - }, - enabled = index > 0, - ) { - Icon( - Icons.Filled.KeyboardArrowUp, - contentDescription = stringResource(R.string.content_desc_move_up), - ) - } - IconButton( - onClick = { - val list = state.selectedNavItems.toMutableList() - val temp = list[index] - list[index] = list[index + 1] - list[index + 1] = temp - viewModel.reorderNavItems(list) - }, - enabled = index < state.selectedNavItems.lastIndex, - ) { - Icon( - Icons.Filled.KeyboardArrowDown, - contentDescription = stringResource(R.string.content_desc_move_down), - ) - } - IconButton(onClick = { viewModel.removeNavItem(name) }) { - Icon( - Icons.Filled.Close, - contentDescription = stringResource(R.string.content_desc_remove_item, label), - ) - } - } - } - } - - if (state.selectedNavItems.size < 5) { - val available = viewModel.availableNavItems.filter { it.first !in state.selectedNavItems } - if (available.isNotEmpty()) { - var expanded by remember { mutableStateOf(false) } - Box { - OutlinedButton(onClick = { expanded = true }) { - Icon(Icons.Filled.Add, contentDescription = null) - Spacer(Modifier.size(4.dp)) - Text(stringResource(R.string.settings_action_add_item)) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - available.forEach { (name, labelRes) -> - DropdownMenuItem( - text = { Text(stringResource(labelRes)) }, - onClick = { - viewModel.addNavItem(name) - expanded = false - }, - ) } } } - } - } else { - Text( - text = stringResource(R.string.settings_nav_max_reached), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - // Test result - AnimatedVisibility( - visible = state.testResult != null, - enter = fadeIn(), - exit = fadeOut(), - ) { - state.testResult?.let { result -> - Card( - colors = - CardDefaults.cardColors( - containerColor = - if (result.startsWith("✅")) { - MaterialTheme.colorScheme.primaryContainer + // Chat section + SectionCard(title = stringResource(R.string.settings_sec_chat)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = stringResource(R.string.settings_item_typing_effect), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.settings_desc_typing_effect), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Switch( + checked = state.typingEffectEnabled, + onCheckedChange = viewModel::onTypingEffectEnabledChange, + colors = + SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + } + + // Delay slider — only visible when effect is enabled + AnimatedVisibility(visible = state.typingEffectEnabled) { + Column(modifier = Modifier.padding(top = 12.dp)) { + Text( + text = + stringResource( + R.string.settings_item_typing_delay, + state.typingEffectDelayMs, + ), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Slider( + value = state.typingEffectDelayMs.toFloat(), + onValueChange = { viewModel.onTypingEffectDelayMsChange(it.toInt()) }, + valueRange = 10f..100f, + steps = 8, // 10, 20, 30, 40, 50, 60, 70, 80, 90, 100 + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.settings_delay_10ms), + style = + MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + Text( + text = stringResource(R.string.settings_delay_100ms), + style = + MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + } + } + } + + // Navigation Bar section + SectionCard(title = stringResource(R.string.settings_sec_nav_bar)) { + Text( + text = stringResource(R.string.settings_item_bottom_nav_display_mode), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + BottomNavDisplayMode.entries.forEachIndexed { index, mode -> + SegmentedButton( + selected = state.bottomNavDisplayMode == mode, + onClick = { viewModel.onBottomNavDisplayModeChange(mode) }, + shape = + SegmentedButtonDefaults.itemShape( + index = index, + count = BottomNavDisplayMode.entries.size, + ), + ) { + Text( + when (mode) { + BottomNavDisplayMode.ICON_AND_TEXT -> { + stringResource( + R.string.settings_display_mode_icon_and_text, + ) + } + + BottomNavDisplayMode.ICON_ONLY -> { + stringResource( + R.string.settings_display_mode_icon_only, + ) + } + + BottomNavDisplayMode.TEXT_ONLY -> { + stringResource( + R.string.settings_display_mode_text_only, + ) + } + }, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.settings_item_nav_items, state.selectedNavItems.size), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + + state.selectedNavItems.forEachIndexed { index, name -> + val labelRes = viewModel.availableNavItems.firstOrNull { it.first == name }?.second + val label = if (labelRes != null) stringResource(labelRes) else name + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton( + onClick = { + val list = state.selectedNavItems.toMutableList() + val temp = list[index] + list[index] = list[index - 1] + list[index - 1] = temp + viewModel.reorderNavItems(list) + }, + enabled = index > 0, + ) { + Icon( + Icons.Filled.KeyboardArrowUp, + contentDescription = stringResource(R.string.content_desc_move_up), + ) + } + IconButton( + onClick = { + val list = state.selectedNavItems.toMutableList() + val temp = list[index] + list[index] = list[index + 1] + list[index + 1] = temp + viewModel.reorderNavItems(list) + }, + enabled = index < state.selectedNavItems.lastIndex, + ) { + Icon( + Icons.Filled.KeyboardArrowDown, + contentDescription = stringResource(R.string.content_desc_move_down), + ) + } + IconButton(onClick = { viewModel.removeNavItem(name) }) { + Icon( + Icons.Filled.Close, + contentDescription = + stringResource( + R.string.content_desc_remove_item, + label, + ), + ) + } + } + } + } + + if (state.selectedNavItems.size < 5) { + val available = + viewModel.availableNavItems.filter { it.first !in state.selectedNavItems } + if (available.isNotEmpty()) { + var expanded by remember { mutableStateOf(false) } + Box { + OutlinedButton(onClick = { expanded = true }) { + Icon(Icons.Filled.Add, contentDescription = null) + Spacer(Modifier.size(4.dp)) + Text(stringResource(R.string.settings_action_add_item)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + available.forEach { (name, labelRes) -> + DropdownMenuItem( + text = { Text(stringResource(labelRes)) }, + onClick = { + viewModel.addNavItem(name) + expanded = false + }, + ) + } + } + } + } + } else { + Text( + text = stringResource(R.string.settings_nav_max_reached), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + SettingsTab.ABOUT -> { + // ── About section ───────────────────────────────────────── + SectionCard(title = stringResource(R.string.settings_sec_about)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.settings_about_app_name), + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + ), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(8.dp)) + + InfoRow( + label = stringResource(R.string.settings_about_version), + value = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", + ) + InfoRow( + label = stringResource(R.string.settings_about_build), + value = + if (BuildConfig.DEBUG) { + stringResource(R.string.settings_about_debug) } else { - MaterialTheme.colorScheme.errorContainer + stringResource(R.string.settings_about_release) }, - ), - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = result, - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.bodyMedium, - ) + ) + if (BuildConfig.GIT_SHA.isNotBlank()) { + InfoRow( + label = stringResource(R.string.settings_about_commit), + value = BuildConfig.GIT_SHA, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "https://github.com/Hy4ri/hermes-mobile", + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.primary, + ), + ) + } } } } - - // Save indicator - AnimatedVisibility( - visible = state.isSaved, - enter = fadeIn(), - exit = fadeOut(), - ) { - Text( - text = stringResource(R.string.settings_save_success), - style = - MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.primary, - ), - ) - } - - // Buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlinedButton( - onClick = viewModel::testConnection, - enabled = !state.isTesting, - modifier = Modifier.weight(1f), - ) { - if (state.isTesting) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - ) - } else { - Text(stringResource(R.string.settings_action_test_connection)) - } - } - - Button( - onClick = viewModel::save, - modifier = Modifier.weight(1f), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(stringResource(R.string.action_save)) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - // ── About section ───────────────────────────────────────── - SectionCard(title = stringResource(R.string.settings_sec_about)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.settings_about_app_name), - style = - MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - ), - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - Spacer(modifier = Modifier.height(8.dp)) - - InfoRow( - label = stringResource(R.string.settings_about_version), - value = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", - ) - InfoRow( - label = stringResource(R.string.settings_about_build), - value = - if (BuildConfig.DEBUG) { - stringResource(R.string.settings_about_debug) - } else { - stringResource(R.string.settings_about_release) - }, - ) - if (BuildConfig.GIT_SHA.isNotBlank()) { - InfoRow(label = stringResource(R.string.settings_about_commit), value = BuildConfig.GIT_SHA) - } - - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = "https://github.com/Hy4ri/hermes-mobile", - style = - MaterialTheme.typography.bodySmall.copy( - color = MaterialTheme.colorScheme.primary, - ), - ) - } - - Spacer(modifier = Modifier.height(16.dp)) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 726baa29..0d45c676 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -105,6 +105,10 @@ Edit + Connection + Appearance + Behavior + About Connection Connection Profile Standalone / Default