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:
1 parent
4314bddaae
commit
b3765bd106
9 files changed
+200
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol
|
package com.m57.hermescontrol
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
@@ -16,11 +17,24 @@ import androidx.compose.ui.Modifier
|
|||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.notification.NotificationReplyReceiver
|
import com.m57.hermescontrol.notification.NotificationReplyReceiver
|
||||||
import com.m57.hermescontrol.theme.HermesControlTheme
|
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||||
|
import com.m57.hermescontrol.util.LocaleContextWrapper
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
// Observable state so both onCreate and onNewIntent updates flow to Compose
|
// Observable state so both onCreate and onNewIntent updates flow to Compose
|
||||||
private var notificationSessionId by mutableStateOf<String?>(null)
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
|||||||
@@ -23,4 +23,7 @@ data class ServerStoreState(
|
|||||||
val wsAuthParam: String = "token",
|
val wsAuthParam: String = "token",
|
||||||
val typingEffectEnabled: Boolean = true,
|
val typingEffectEnabled: Boolean = true,
|
||||||
val typingEffectDelayMs: Int = 30,
|
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) }
|
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 getTypingEffectDelayMs(): Int = serverStore.getLatestState().typingEffectDelayMs
|
||||||
|
|
||||||
fun setTypingEffectDelayMs(delayMs: Int) {
|
fun setTypingEffectDelayMs(delayMs: Int) {
|
||||||
|
|||||||
@@ -163,6 +163,8 @@ fun SettingsScreen(
|
|||||||
onUseDynamicColorsChange = viewModel::onUseDynamicColorsChange,
|
onUseDynamicColorsChange = viewModel::onUseDynamicColorsChange,
|
||||||
themePreset = state.themePreset,
|
themePreset = state.themePreset,
|
||||||
onThemePresetChange = viewModel::onThemePresetChange,
|
onThemePresetChange = viewModel::onThemePresetChange,
|
||||||
|
appLanguage = state.appLanguage,
|
||||||
|
onAppLanguageChange = viewModel::onAppLanguageChange,
|
||||||
)
|
)
|
||||||
|
|
||||||
ChatSection(
|
ChatSection(
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ data class SettingsUiState(
|
|||||||
val selectedProfileId: String? = null,
|
val selectedProfileId: String? = null,
|
||||||
val renameProfileName: String = "",
|
val renameProfileName: String = "",
|
||||||
val bottomNavDisplayMode: BottomNavDisplayMode = BottomNavDisplayMode.ICON_AND_TEXT,
|
val bottomNavDisplayMode: BottomNavDisplayMode = BottomNavDisplayMode.ICON_AND_TEXT,
|
||||||
|
val appLanguage: String = "system",
|
||||||
// Profile add/edit dialog
|
// Profile add/edit dialog
|
||||||
val showProfileDialog: Boolean = false,
|
val showProfileDialog: Boolean = false,
|
||||||
val editingProfileId: String? = null,
|
val editingProfileId: String? = null,
|
||||||
@@ -79,6 +80,7 @@ class SettingsViewModel(
|
|||||||
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
|
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
|
||||||
val profiles = AuthManager.getConnectionProfiles()
|
val profiles = AuthManager.getConnectionProfiles()
|
||||||
val bottomNavDisplayMode = AuthManager.getBottomNavDisplayMode()
|
val bottomNavDisplayMode = AuthManager.getBottomNavDisplayMode()
|
||||||
|
val appLanguage = AuthManager.getAppLanguage()
|
||||||
val renameProfileName =
|
val renameProfileName =
|
||||||
profiles.firstOrNull { p -> p.id == selectedId }?.name ?: ""
|
profiles.firstOrNull { p -> p.id == selectedId }?.name ?: ""
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
@@ -97,6 +99,7 @@ class SettingsViewModel(
|
|||||||
selectedProfileId = selectedId,
|
selectedProfileId = selectedId,
|
||||||
renameProfileName = renameProfileName,
|
renameProfileName = renameProfileName,
|
||||||
bottomNavDisplayMode = bottomNavDisplayMode,
|
bottomNavDisplayMode = bottomNavDisplayMode,
|
||||||
|
appLanguage = appLanguage,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,6 +299,11 @@ class SettingsViewModel(
|
|||||||
AuthManager.setThemePreset(preset)
|
AuthManager.setThemePreset(preset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onAppLanguageChange(code: String) {
|
||||||
|
_uiState.update { it.copy(appLanguage = code, isSaved = false) }
|
||||||
|
AuthManager.setAppLanguage(code)
|
||||||
|
}
|
||||||
|
|
||||||
fun onBottomNavDisplayModeChange(mode: BottomNavDisplayMode) {
|
fun onBottomNavDisplayModeChange(mode: BottomNavDisplayMode) {
|
||||||
_uiState.update { it.copy(bottomNavDisplayMode = mode, isSaved = false) }
|
_uiState.update { it.copy(bottomNavDisplayMode = mode, isSaved = false) }
|
||||||
AuthManager.setBottomNavDisplayMode(mode)
|
AuthManager.setBottomNavDisplayMode(mode)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.settings.components
|
package com.m57.hermescontrol.ui.settings.components
|
||||||
|
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.ThemePreference
|
||||||
import com.m57.hermescontrol.theme.ThemePreset
|
import com.m57.hermescontrol.theme.ThemePreset
|
||||||
import com.m57.hermescontrol.ui.settings.SectionCard
|
import com.m57.hermescontrol.ui.settings.SectionCard
|
||||||
|
import com.m57.hermescontrol.util.LocaleContextWrapper
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AppearanceSection(
|
internal fun AppearanceSection(
|
||||||
@@ -52,6 +54,8 @@ internal fun AppearanceSection(
|
|||||||
onUseDynamicColorsChange: (Boolean) -> Unit,
|
onUseDynamicColorsChange: (Boolean) -> Unit,
|
||||||
themePreset: ThemePreset,
|
themePreset: ThemePreset,
|
||||||
onThemePresetChange: (ThemePreset) -> Unit,
|
onThemePresetChange: (ThemePreset) -> Unit,
|
||||||
|
appLanguage: String,
|
||||||
|
onAppLanguageChange: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
SectionCard(title = stringResource(R.string.settings_sec_appearance)) {
|
SectionCard(title = stringResource(R.string.settings_sec_appearance)) {
|
||||||
Text(
|
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))
|
||||||
|
}
|
||||||
@@ -207,6 +207,12 @@
|
|||||||
<string name="theme_preset_amoled">AMOLED Black (Dark only)</string>
|
<string name="theme_preset_amoled">AMOLED Black (Dark only)</string>
|
||||||
<string name="theme_preset_neon_noir">Neon Noir</string>
|
<string name="theme_preset_neon_noir">Neon Noir</string>
|
||||||
|
|
||||||
|
<!-- Language -->
|
||||||
|
<string name="settings_item_language">Language</string>
|
||||||
|
<string name="language_system">System default</string>
|
||||||
|
<string name="language_english">English</string>
|
||||||
|
<string name="language_korean">한국어</string>
|
||||||
|
|
||||||
<!-- Connect Screen -->
|
<!-- Connect Screen -->
|
||||||
<string name="connect_selected_profile">Profile: %1$s</string>
|
<string name="connect_selected_profile">Profile: %1$s</string>
|
||||||
<string name="connect_action_select_profile">Select Saved Profile</string>
|
<string name="connect_action_select_profile">Select Saved Profile</string>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.m57.hermescontrol.util
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class LocaleContextWrapperTest {
|
||||||
|
@Test
|
||||||
|
fun systemReturnsDeviceDefault() {
|
||||||
|
assertEquals(Locale.getDefault(), LocaleContextWrapper.localeForCode("system"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyReturnsDeviceDefault() {
|
||||||
|
assertEquals(Locale.getDefault(), LocaleContextWrapper.localeForCode(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bareLanguageCode() {
|
||||||
|
val locale = LocaleContextWrapper.localeForCode("ko")
|
||||||
|
assertEquals("ko", locale.language)
|
||||||
|
assertEquals("", locale.country)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun regionSeparator_r() {
|
||||||
|
val locale = LocaleContextWrapper.localeForCode("zh-rCN")
|
||||||
|
assertEquals("zh", locale.language)
|
||||||
|
assertEquals("CN", locale.country)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun regionSeparator_hyphen() {
|
||||||
|
val locale = LocaleContextWrapper.localeForCode("pt-BR")
|
||||||
|
assertEquals("pt", locale.language)
|
||||||
|
assertEquals("BR", locale.country)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun regionSeparator_underscore() {
|
||||||
|
val locale = LocaleContextWrapper.localeForCode("en_US")
|
||||||
|
assertEquals("en", locale.language)
|
||||||
|
assertEquals("US", locale.country)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user