feat: in-app display language switcher (Settings → Appearance) (#604)

Add a Language selector to Settings → Appearance that overrides the app
display language independent of the device locale. Persists a BCP-47 code
in ServerStoreState.appLanguage; MainActivity.attachBaseContext applies it
via a ContextWrapper (works on minSdk 26, no AppCompat dependency needed
since the Activity is a plain ComponentActivity). Selecting a language
recreates the activity so resources re-resolve immediately.

Includes System default / English / 한국어 options, plus a unit test for
the locale-code resolver.

Note: the new string keys (settings_item_language, language_system,
language_english, language_korean) should be backfilled into values-ko
once PR #602 lands.

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andhy4ri authored and GitHub committed 2026-07-15 23:57:39 +03:00
1 parent 4314bddaae
commit b3765bd106
9 files changed
+200

No files matched your search

@@ -1,5 +1,6 @@
package com.m57.hermescontrol
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -16,11 +17,24 @@ import androidx.compose.ui.Modifier
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.notification.NotificationReplyReceiver
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.util.LocaleContextWrapper
class MainActivity : ComponentActivity() {
// Observable state so both onCreate and onNewIntent updates flow to Compose
private var notificationSessionId by mutableStateOf<String?>(null)
/**
* Apply the user-selected display language before any view is inflated.
* Reads the persisted code from [AuthManager]; an uninitialized store
* (shouldn't happen here, but guarded) falls back to the device locale.
*/
override fun attachBaseContext(base: Context) {
val code =
runCatching { AuthManager.getAppLanguage() }
.getOrDefault(LocaleContextWrapper.SYSTEM_LANGUAGE)
super.attachBaseContext(LocaleContextWrapper.wrapWithCode(base, code))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -23,4 +23,7 @@ data class ServerStoreState(
val wsAuthParam: String = "token",
val typingEffectEnabled: Boolean = true,
val typingEffectDelayMs: Int = 30,
// App display language. "system" = follow device locale; otherwise a BCP-47
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
val appLanguage: String = "system",
)
@@ -498,6 +498,15 @@ object AuthManager {
serverStore.update { it.copy(typingEffectEnabled = enabled) }
}
// ── App display language ───────────────────────────────────────────
// "system" follows the device locale; any other value is a BCP-47 code.
fun getAppLanguage(): String = serverStore.getLatestState().appLanguage
fun setAppLanguage(code: String) {
serverStore.update { it.copy(appLanguage = code) }
}
fun getTypingEffectDelayMs(): Int = serverStore.getLatestState().typingEffectDelayMs
fun setTypingEffectDelayMs(delayMs: Int) {
@@ -163,6 +163,8 @@ fun SettingsScreen(
onUseDynamicColorsChange = viewModel::onUseDynamicColorsChange,
themePreset = state.themePreset,
onThemePresetChange = viewModel::onThemePresetChange,
appLanguage = state.appLanguage,
onAppLanguageChange = viewModel::onAppLanguageChange,
)
ChatSection(
@@ -39,6 +39,7 @@ data class SettingsUiState(
val selectedProfileId: String? = null,
val renameProfileName: String = "",
val bottomNavDisplayMode: BottomNavDisplayMode = BottomNavDisplayMode.ICON_AND_TEXT,
val appLanguage: String = "system",
// Profile add/edit dialog
val showProfileDialog: Boolean = false,
val editingProfileId: String? = null,
@@ -79,6 +80,7 @@ class SettingsViewModel(
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
val profiles = AuthManager.getConnectionProfiles()
val bottomNavDisplayMode = AuthManager.getBottomNavDisplayMode()
val appLanguage = AuthManager.getAppLanguage()
val renameProfileName =
profiles.firstOrNull { p -> p.id == selectedId }?.name ?: ""
_uiState.update {
@@ -97,6 +99,7 @@ class SettingsViewModel(
selectedProfileId = selectedId,
renameProfileName = renameProfileName,
bottomNavDisplayMode = bottomNavDisplayMode,
appLanguage = appLanguage,
)
}
}
@@ -296,6 +299,11 @@ class SettingsViewModel(
AuthManager.setThemePreset(preset)
}
fun onAppLanguageChange(code: String) {
_uiState.update { it.copy(appLanguage = code, isSaved = false) }
AuthManager.setAppLanguage(code)
}
fun onBottomNavDisplayModeChange(mode: BottomNavDisplayMode) {
_uiState.update { it.copy(bottomNavDisplayMode = mode, isSaved = false) }
AuthManager.setBottomNavDisplayMode(mode)
@@ -1,5 +1,6 @@
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
@@ -43,6 +44,7 @@ import com.m57.hermescontrol.theme.BottomNavDisplayMode
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(
@@ -52,6 +54,8 @@ internal fun AppearanceSection(
onUseDynamicColorsChange: (Boolean) -> Unit,
themePreset: ThemePreset,
onThemePresetChange: (ThemePreset) -> Unit,
appLanguage: String,
onAppLanguageChange: (String) -> Unit,
) {
SectionCard(title = stringResource(R.string.settings_sec_appearance)) {
Text(
@@ -193,6 +197,42 @@ 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),
"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,73 @@
package com.m57.hermescontrol.util
import android.content.Context
import android.content.ContextWrapper
import android.os.Build
import java.util.Locale
/**
* Static helpers for applying a user-selected in-app display language.
*
* Hermes Mobile's [com.m57.hermescontrol.MainActivity] extends the plain
* `ComponentActivity` (not `AppCompatActivity`), so we apply the locale through
* `attachBaseContext` + [ContextWrapper.wrap] rather than relying on
* AppCompatDelegate. Works on minSdk 26.
*/
object LocaleContextWrapper {
const val SYSTEM_LANGUAGE = "system"
/**
* Resolve a language code ("system", "en", "ko", …) into a [Locale].
*
* "system" returns the device's default locale (so passing it through
* [wrap] is effectively a no-op). Codes containing a region separator
* ("zh-rCN", "pt-BR") are split into language + country.
*/
fun localeForCode(code: String): Locale =
when {
code.isEmpty() || code == SYSTEM_LANGUAGE -> Locale.getDefault()
code.contains("-r", ignoreCase = true) -> {
val parts = code.split("-r", ignoreCase = true)
Locale(parts[0], parts.getOrElse(1) { "" })
}
code.contains("-") -> {
val parts = code.split("-")
Locale(parts[0], parts.getOrElse(1) { "" })
}
code.contains("_") -> {
val parts = code.split("_")
Locale(parts[0], parts.getOrElse(1) { "" })
}
else -> Locale(code)
}
/**
* Wrap [base] so the configuration carries [locale].
*
* On API 33+ we use [Context.createConfigurationContext] which is the only
* reliable path once the deprecated `Configuration.locale` / `setLocale`
* APIs were removed from the public surface.
*/
fun wrap(
base: Context,
locale: Locale,
): Context {
val config = base.resources.configuration
val newConfig =
android.content.res.Configuration(config).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
setLocales(android.os.LocaleList(locale))
} else {
@Suppress("DEPRECATION")
setLocale(locale)
}
}
return base.createConfigurationContext(newConfig)
}
/** Convenience: wrap [base] using a stored language code. */
fun wrapWithCode(
base: Context,
code: String,
): Context = wrap(base, localeForCode(code))
}