feat(settings): move language to its own section with scalable dropdown picker (#906)

- New Language row in the main Settings list with current-language summary
- New SettingsLanguage drill-down page with an OutlinedButton + DropdownMenu
  picker (scales to any number of languages; same pattern as theme presets)
- Shared supportedLanguages()/languageLabel() helpers — adding a language
  is now one entry in one place
- Removed the 4-option segmented pill row from the Appearance page
- Added settings_sec_language string (en + zh)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-15 12:18:37 +03:00
1 parent fdbd98af3e
commit 063c27b141
8 files changed
+143 -57

No files matched your search

@@ -56,6 +56,7 @@ import com.m57.hermescontrol.ui.settings.SettingsAppearancePage
import com.m57.hermescontrol.ui.settings.SettingsBehaviorPage
import com.m57.hermescontrol.ui.settings.SettingsChatPage
import com.m57.hermescontrol.ui.settings.SettingsConnectionPage
import com.m57.hermescontrol.ui.settings.SettingsLanguagePage
import com.m57.hermescontrol.ui.settings.SettingsViewModel
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
import kotlinx.coroutines.launch
@@ -113,6 +114,12 @@ private fun appEntryProvider(
viewModel = viewModel { SettingsViewModel() },
)
}
entry<SettingsLanguage> {
SettingsLanguagePage(
onBack = { NavigationController.goBack() },
viewModel = viewModel { SettingsViewModel() },
)
}
entry<SettingsChat> {
SettingsChatPage(
onBack = { NavigationController.goBack() },
@@ -75,6 +75,8 @@ import kotlinx.serialization.Serializable
@Serializable data object SettingsAppearance : NavKey
@Serializable data object SettingsLanguage : NavKey
@Serializable data object SettingsChat : NavKey
@Serializable data object SettingsBehavior : NavKey
@@ -12,6 +12,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.automirrored.filled.ListAlt
import androidx.compose.material.icons.filled.ChatBubbleOutline
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Card
@@ -39,9 +40,11 @@ import com.m57.hermescontrol.SettingsAppearance
import com.m57.hermescontrol.SettingsBehavior
import com.m57.hermescontrol.SettingsChat
import com.m57.hermescontrol.SettingsConnection
import com.m57.hermescontrol.SettingsLanguage
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.settings.components.languageLabel
@Composable
fun SettingsScreen(
@@ -87,6 +90,12 @@ fun SettingsScreen(
summary = appearanceSummary(state),
onClick = { NavigationController.navigateTo(SettingsAppearance) },
),
SettingsRow(
icon = Icons.Filled.Language,
label = stringResource(R.string.settings_sec_language),
summary = languageLabel(state.appLanguage),
onClick = { NavigationController.navigateTo(SettingsLanguage) },
),
SettingsRow(
icon = Icons.Filled.ChatBubbleOutline,
label = stringResource(R.string.settings_sec_chat),
@@ -121,22 +130,12 @@ fun SettingsScreen(
}
@Composable
private fun appearanceSummary(state: SettingsUiState): String {
val theme =
when (state.themePreference) {
ThemePreference.SYSTEM -> stringResource(R.string.settings_summary_theme_system)
ThemePreference.LIGHT -> stringResource(R.string.settings_summary_theme_light)
ThemePreference.DARK -> stringResource(R.string.settings_summary_theme_dark)
}
val lang =
when (state.appLanguage) {
"system" -> stringResource(R.string.language_system)
"zh" -> stringResource(R.string.language_chinese)
"ko" -> stringResource(R.string.language_korean)
else -> stringResource(R.string.language_english)
}
return "$theme · $lang"
}
private fun appearanceSummary(state: SettingsUiState): String =
when (state.themePreference) {
ThemePreference.SYSTEM -> stringResource(R.string.settings_summary_theme_system)
ThemePreference.LIGHT -> stringResource(R.string.settings_summary_theme_light)
ThemePreference.DARK -> stringResource(R.string.settings_summary_theme_dark)
}
private data class SettingsRow(
val icon: ImageVector,
@@ -36,6 +36,7 @@ import com.m57.hermescontrol.ui.settings.components.AppearanceSection
import com.m57.hermescontrol.ui.settings.components.BehaviorSection
import com.m57.hermescontrol.ui.settings.components.ChatSection
import com.m57.hermescontrol.ui.settings.components.ConnectionSection
import com.m57.hermescontrol.ui.settings.components.LanguageSection
import com.m57.hermescontrol.ui.settings.components.TestConnectionButton
import com.m57.hermescontrol.ui.settings.components.TestResultCard
@@ -132,6 +133,32 @@ internal fun SettingsAppearancePage(
onUseDynamicColorsChange = viewModel::onUseDynamicColorsChange,
themePreset = state.themePreset,
onThemePresetChange = viewModel::onThemePresetChange,
)
}
}
}
@Composable
internal fun SettingsLanguagePage(
onBack: () -> Unit,
viewModel: SettingsViewModel = viewModel { SettingsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
HermesScaffold(
title = { Text(stringResource(R.string.settings_sec_language)) },
navigationIcon = NavIcon.Back(onBack),
drawerGesturesEnabled = false,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
LanguageSection(
appLanguage = state.appLanguage,
onAppLanguageChange = viewModel::onAppLanguageChange,
)
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.ui.settings.components
import androidx.activity.compose.LocalActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -35,7 +34,6 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.ui.settings.SectionCard
import com.m57.hermescontrol.util.LocaleContextWrapper
@Composable
internal fun AppearanceSection(
@@ -45,8 +43,6 @@ internal fun AppearanceSection(
onUseDynamicColorsChange: (Boolean) -> Unit,
themePreset: ThemePreset,
onThemePresetChange: (ThemePreset) -> Unit,
appLanguage: String,
onAppLanguageChange: (String) -> Unit,
) {
SectionCard {
Text(
@@ -189,43 +185,6 @@ internal fun AppearanceSection(
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.settings_item_language),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(8.dp))
val languageOptions =
listOf(
LocaleContextWrapper.SYSTEM_LANGUAGE to stringResource(R.string.language_system),
"en" to stringResource(R.string.language_english),
"zh" to stringResource(R.string.language_chinese),
"ko" to stringResource(R.string.language_korean),
)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
val activity = LocalActivity.current
languageOptions.forEachIndexed { index, (code, label) ->
SegmentedButton(
selected = appLanguage == code,
onClick = {
onAppLanguageChange(code)
// MainActivity is a plain ComponentActivity (not
// AppCompatActivity), so the locale only takes effect
// after the activity is recreated.
activity?.recreate()
},
shape =
SegmentedButtonDefaults.itemShape(
index = index,
count = languageOptions.size,
),
) {
Text(label)
}
}
}
}
}
@@ -0,0 +1,90 @@
package com.m57.hermescontrol.ui.settings.components
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.settings.SectionCard
import com.m57.hermescontrol.util.LocaleContextWrapper
/**
* Supported languages as (code, display label) pairs. Add a new language
* here (plus its label in strings.xml) and both the picker and the summary
* adapt automatically.
*/
@Composable
internal fun supportedLanguages(): List<Pair<String, String>> =
listOf(
LocaleContextWrapper.SYSTEM_LANGUAGE to stringResource(R.string.language_system),
"en" to stringResource(R.string.language_english),
"zh" to stringResource(R.string.language_chinese),
"ko" to stringResource(R.string.language_korean),
)
/** Display label for a language code, falling back to English for unknown codes. */
@Composable
internal fun languageLabel(code: String): String =
supportedLanguages().firstOrNull { it.first == code }?.second
?: stringResource(R.string.language_english)
@Composable
internal fun LanguageSection(
appLanguage: String,
onAppLanguageChange: (String) -> Unit,
) {
SectionCard {
Text(
text = stringResource(R.string.settings_item_language),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(8.dp))
val languageOptions = supportedLanguages()
val activity = LocalActivity.current
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { expanded = true },
modifier = Modifier.fillMaxWidth(),
) {
Text(languageLabel(appLanguage))
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.fillMaxWidth(0.85f),
) {
languageOptions.forEach { (code, label) ->
DropdownMenuItem(
text = { Text(label) },
onClick = {
expanded = false
if (code != appLanguage) {
onAppLanguageChange(code)
// MainActivity is a plain ComponentActivity
// (not AppCompatActivity), so the locale only
// takes effect after the activity is recreated.
activity?.recreate()
}
},
)
}
}
}
}
}